mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-13 04:36:37 +08:00
5466 lines
1.4 MiB
Plaintext
5466 lines
1.4 MiB
Plaintext
{
|
||
"info": {
|
||
"id": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"slug": "playful-knight",
|
||
"projectID": "0d4685396cc353e0f47ffbe712b4e8d0545f5bad",
|
||
"directory": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"title": "全量任务逐一确认与完成情况分析",
|
||
"version": "1.2.15",
|
||
"summary": {
|
||
"additions": 2764,
|
||
"deletions": 4,
|
||
"files": 10
|
||
},
|
||
"time": {
|
||
"created": 1772500185938,
|
||
"updated": 1772504187925
|
||
}
|
||
},
|
||
"messages": [
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772500186079
|
||
},
|
||
"summary": {
|
||
"diffs": []
|
||
},
|
||
"agent": "build",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "211任务全量再逐一确认一下是否全部完成,并且功能完成",
|
||
"id": "prt_cb13e73bc002sxaXc55SRQpKWQ",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73bc001fBbvd7bAbBIy0D"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772500186086,
|
||
"completed": 1772500190938
|
||
},
|
||
"parentID": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 13789,
|
||
"input": 13629,
|
||
"output": 160,
|
||
"reasoning": 96,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb13e73e6001WvCjwaHgAeV65J",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"id": "prt_cb13e81fc00130TdreIDbiyJur",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73e6001WvCjwaHgAeV65J"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Searching for task 211 references**",
|
||
"time": {
|
||
"start": 1772500189693,
|
||
"end": 1772500190045
|
||
},
|
||
"id": "prt_cb13e81fd001pM9zWgKKHfnCGO",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73e6001WvCjwaHgAeV65J"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_1tb95U34FgFnAAq8VHCKsGN2",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "211",
|
||
"path": ".",
|
||
"include": "*"
|
||
},
|
||
"output": "Found 1042 matches (showing first 100)\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/dist/assets/index-mjdXO_rb.js:\n Line 20406: \t\td: \"M17 11h1a3 3 0 0 1 2.745 4.211\",\n Line 23623: \t\td: \"M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z\",\n Line 37653: \t\td: \"M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z\",\n Line 39537: \t\td: \"M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z\",\n Line 42997: \t\td: \"M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z\",\n Line 53859: \t\td: \"M19.5 8.5C19.8978 8.10218 20.2374 7.63877 20.5 7.12132C20.7626 6.60387 20.8478 6.03677 20.8478 5.464C20.8478 4.89123 20.7626 4.32413 20.5 3.80668C20.2374 3.28923 19.8978 2.82582 19.5 2.428C19.1022 2.03018 18.6388 1.69063 18.1213 1.42801C17.6039 1.16539 17.0368 1.08023 16.464 1.08023C15.8912 1.08023 15.3241 1.16539 14.8067 1.42801C14.2892 1.69063 13.8258 2.03018 13.428 2.428L12 3.856L10.572 2.428C9.78889 1.64486 8.73968 1.21408 7.64800 1.21408C6.55632 1.21408 5.50711 1.64486 4.72400 2.428C3.94086 3.21111 3.51008 4.26032 3.51008 5.352C3.51008 6.44368 3.94086 7.49289 4.72400 8.276L6.15200 9.704L12 15.552L17.848 9.704L19.276 8.276C19.5 8.052 19.5 8.276 19.5 8.5Z\",\n Line 76823: \tmediumorchid: 12211667,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/.package-lock.json:\n Line 6503: \"integrity\": \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/package-lock.json:\n Line 7329: \"integrity\": \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/spotlight.js.map:\n Line 1: {\"version\":3,\"file\":\"spotlight.js\",\"sources\":[\"../../../src/icons/spotlight.ts\"],\"sourcesContent\":[\"import createLucideIcon from '../createLucideIcon';\\nimport { IconNode } from '../types';\\n\\nexport const __iconNode: IconNode = [\\n ['path', { d: 'M15.295 19.562 16 22', key: '31jsb7' }],\\n ['path', { d: 'm17 16 3.758 2.098', key: '121ar7' }],\\n ['path', { d: 'm19 12.5 3.026-.598', key: '19ukd3' }],\\n [\\n 'path',\\n {\\n d: 'M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z',\\n key: 'lwb9l9',\\n },\\n ],\\n ['path', { d: 'M8 9V2', key: '1xa0v7' }],\\n];\\n\\n/**\\n * @component @name Spotlight\\n * @description Lucide SVG icon component, renders SVG Element with children.\\n *\\n * @preview  - https://lucide.dev/icons/spotlight\\n * @see https://lucide.dev/guide/packages/lucide-react - Documentation\\n *\\n * @param {Object} props - Lucide icons props and any valid SVG attribute\\n * @returns {JSX.Element} JSX Element\\n *\\n */\\nconst Spotlight = createLucideIcon('spotlight', __iconNode);\\n\\nexport default Spotlight;\\n\"],\"names\":[],\"mappings\":\";;;;;;;;;AAGO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAM,UAAA,CAAA,CAAA,CAAuB;AAAA,CAAA,CAClC,CAAC,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAQ,CAAA,C...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/radiation.js.map:\n Line 1: {\"version\":3,\"file\":\"radiation.js\",\"sources\":[\"../../../src/icons/radiation.ts\"],\"sourcesContent\":[\"import createLucideIcon from '../createLucideIcon';\\nimport { IconNode } from '../types';\\n\\nexport const __iconNode: IconNode = [\\n ['path', { d: 'M12 12h.01', key: '1mp3jc' }],\\n [\\n 'path',\\n {\\n d: 'M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z',\\n key: '1y4lzb',\\n },\\n ],\\n [\\n 'path',\\n {\\n d: 'M16 12a4 4 0 0 0-2-3.464l2.472-4.282a1 1 0 0 1 1.46-.305 10 10 0 0 1 4.006 6.94A1 1 0 0 1 21 12z',\\n key: '163ggk',\\n },\\n ],\\n [\\n 'path',\\n {\\n d: 'M8 12a4 4 0 0 1 2-3.464L7.528 4.254a1 1 0 0 0-1.46-.305 10 10 0 0 0-4.006 6.94A1 1 0 0 0 3 12z',\\n key: '1l9i0b',\\n },\\n ],\\n];\\n\\n/**\\n * @component @name Radiation\\n * @description Lucide SVG icon component, renders SVG Element with children.\\n *\\n * @preview  - https://lucide.dev/icons/radiation\\n * @see https://lucide.dev/guide/packages/lucide-react - Documentation\\n *\\n * @param {Object} props - L...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/package-2.js.map:\n Line 1: {\"version\":3,\"file\":\"package-2.js\",\"sources\":[\"../../../src/icons/package-2.ts\"],\"sourcesContent\":[\"import createLucideIcon from '../createLucideIcon';\\nimport { IconNode } from '../types';\\n\\nexport const __iconNode: IconNode = [\\n ['path', { d: 'M12 3v6', key: '1holv5' }],\\n [\\n 'path',\\n {\\n d: 'M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z',\\n key: '187q7i',\\n },\\n ],\\n ['path', { d: 'M3.054 9.013h17.893', key: 'grwhos' }],\\n];\\n\\n/**\\n * @component @name Package2\\n * @description Lucide SVG icon component, renders SVG Element with children.\\n *\\n * @preview  - https://lucide.dev/icons/package-2\\n * @see https://lucide.dev/guide/packages/lucide-react - Documentation\\n *\\n * @param {Object} props - Lucide icons props and any valid SVG attribute\\n * @returns {JSX.Element} JSX Element\\n *\\n */\\nconst Package2 = createLucideIcon('package-2', __iconNode);\\n\\nexport default Package2;\\n\"],\"names\":[],\"mappings\":\";;;;;;;;;AAGO,CAAA,CAAA,CAAA,CAAA,CAAA,CAAM,UAAA,CAAA,CAAA,CAAuB;AAAA,CAAA,CAClC,CAAC,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAQ,CAAA,CAAE,GAAG,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,CAAA,CAAA,CAAA,CAAA,CAAK,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,GAAU,CAAA;AAAA,CAAA,CACxC;AAAA,CAAA,CAAA,CAAA,CAC...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/umd/lucide-react.min.js.map:\n Line 1: {\"version\":3,\"file\":\"lucide-react.min.js\",\"sources\":[\"../../../shared/src/utils.ts\",\"../../src/defaultAttributes.ts\",\"../../src/Icon.ts\",\"../../src/createLucideIcon.ts\",\"../../src/icons/a-arrow-down.ts\",\"../../src/icons/a-arrow-up.ts\",\"../../src/icons/a-large-small.ts\",\"../../src/icons/accessibility.ts\",\"../../src/icons/activity.ts\",\"../../src/icons/air-vent.ts\",\"../../src/icons/airplay.ts\",\"../../src/icons/alarm-clock-check.ts\",\"../../src/icons/alarm-clock-minus.ts\",\"../../src/icons/alarm-clock-off.ts\",\"../../src/icons/alarm-clock-plus.ts\",\"../../src/icons/alarm-clock.ts\",\"../../src/icons/alarm-smoke.ts\",\"../../src/icons/album.ts\",\"../../src/icons/align-center-horizontal.ts\",\"../../src/icons/align-center.ts\",\"../../src/icons/align-center-vertical.ts\",\"../../src/icons/align-end-horizontal.ts\",\"../../src/icons/align-end-vertical.ts\",\"../../src/icons/align-horizontal-distribute-center.ts\",\"../../src/icons/align-horizontal-distribute-end.ts\",\"../../src/icons/align-horizontal-distribute-start.ts\",\"../../src/icons/align-horizontal-justify-center.ts\",\"../../src/icons/align-horizontal-justify-end.ts\",\"../../src/icons/align-horizontal-justify-start.ts\",\"../../src/icons/align-horizontal-space-around.ts\",\"../../src/icons/align-horizontal-space-between.ts\",\"../../src/icons/align-justify.ts\",\"../../src/icons/align-left.ts\",\"../../src/icons/align-right.ts\",\"../../src/icons/align-start-horizontal.ts\",\"../../src/icons/align-start-vertical.ts\",\"../../src/icons/align-vertical-distribute-center.ts\",\"../../src/icons/align-vertical-distribute-end.ts\",\"../../src/icons/align-vertical-distribute-start.ts\",\"../../src/icons/align-vertical-justify-center.ts\",\"../../src/icons/align-vertical-justify-end.ts\",\"../../src/icons/align-vertical-justify-start.ts\",\"../../src/icons/align-vertical-space-around.ts\",\"../../src/icons/align-vertical-space-between.ts\",\"../../src/icons/ambulance.ts\",\"../../src/icons/ampersand.ts\",\"../../src/icons/ampersands.ts\",\"../../src/icons/amphora.ts\",\"../../src/icons/an...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/umd/lucide-react.js.map:\n Line 1: {\"version\":3,\"file\":\"lucide-react.js\",\"sources\":[\"../../../shared/src/utils.ts\",\"../../src/defaultAttributes.ts\",\"../../src/Icon.ts\",\"../../src/createLucideIcon.ts\",\"../../src/icons/a-arrow-down.ts\",\"../../src/icons/a-arrow-up.ts\",\"../../src/icons/a-large-small.ts\",\"../../src/icons/accessibility.ts\",\"../../src/icons/activity.ts\",\"../../src/icons/air-vent.ts\",\"../../src/icons/airplay.ts\",\"../../src/icons/alarm-clock-check.ts\",\"../../src/icons/alarm-clock-minus.ts\",\"../../src/icons/alarm-clock-off.ts\",\"../../src/icons/alarm-clock-plus.ts\",\"../../src/icons/alarm-clock.ts\",\"../../src/icons/alarm-smoke.ts\",\"../../src/icons/album.ts\",\"../../src/icons/align-center-horizontal.ts\",\"../../src/icons/align-center.ts\",\"../../src/icons/align-center-vertical.ts\",\"../../src/icons/align-end-horizontal.ts\",\"../../src/icons/align-end-vertical.ts\",\"../../src/icons/align-horizontal-distribute-center.ts\",\"../../src/icons/align-horizontal-distribute-end.ts\",\"../../src/icons/align-horizontal-distribute-start.ts\",\"../../src/icons/align-horizontal-justify-center.ts\",\"../../src/icons/align-horizontal-justify-end.ts\",\"../../src/icons/align-horizontal-justify-start.ts\",\"../../src/icons/align-horizontal-space-around.ts\",\"../../src/icons/align-horizontal-space-between.ts\",\"../../src/icons/align-justify.ts\",\"../../src/icons/align-left.ts\",\"../../src/icons/align-right.ts\",\"../../src/icons/align-start-horizontal.ts\",\"../../src/icons/align-start-vertical.ts\",\"../../src/icons/align-vertical-distribute-center.ts\",\"../../src/icons/align-vertical-distribute-end.ts\",\"../../src/icons/align-vertical-distribute-start.ts\",\"../../src/icons/align-vertical-justify-center.ts\",\"../../src/icons/align-vertical-justify-end.ts\",\"../../src/icons/align-vertical-justify-start.ts\",\"../../src/icons/align-vertical-space-around.ts\",\"../../src/icons/align-vertical-space-between.ts\",\"../../src/icons/ambulance.ts\",\"../../src/icons/ampersand.ts\",\"../../src/icons/ampersands.ts\",\"../../src/icons/amphora.ts\",\"../../src/icons/anchor...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/cjs/lucide-react.js.map:\n Line 1: {\"version\":3,\"file\":\"lucide-react.js\",\"sources\":[\"../../../shared/src/utils.ts\",\"../../src/defaultAttributes.ts\",\"../../src/Icon.ts\",\"../../src/createLucideIcon.ts\",\"../../src/icons/a-arrow-down.ts\",\"../../src/icons/a-arrow-up.ts\",\"../../src/icons/a-large-small.ts\",\"../../src/icons/accessibility.ts\",\"../../src/icons/activity.ts\",\"../../src/icons/air-vent.ts\",\"../../src/icons/airplay.ts\",\"../../src/icons/alarm-clock-check.ts\",\"../../src/icons/alarm-clock-minus.ts\",\"../../src/icons/alarm-clock-off.ts\",\"../../src/icons/alarm-clock-plus.ts\",\"../../src/icons/alarm-clock.ts\",\"../../src/icons/alarm-smoke.ts\",\"../../src/icons/album.ts\",\"../../src/icons/align-center-horizontal.ts\",\"../../src/icons/align-center.ts\",\"../../src/icons/align-center-vertical.ts\",\"../../src/icons/align-end-horizontal.ts\",\"../../src/icons/align-end-vertical.ts\",\"../../src/icons/align-horizontal-distribute-center.ts\",\"../../src/icons/align-horizontal-distribute-end.ts\",\"../../src/icons/align-horizontal-distribute-start.ts\",\"../../src/icons/align-horizontal-justify-center.ts\",\"../../src/icons/align-horizontal-justify-end.ts\",\"../../src/icons/align-horizontal-justify-start.ts\",\"../../src/icons/align-horizontal-space-around.ts\",\"../../src/icons/align-horizontal-space-between.ts\",\"../../src/icons/align-justify.ts\",\"../../src/icons/align-left.ts\",\"../../src/icons/align-right.ts\",\"../../src/icons/align-start-horizontal.ts\",\"../../src/icons/align-start-vertical.ts\",\"../../src/icons/align-vertical-distribute-center.ts\",\"../../src/icons/align-vertical-distribute-end.ts\",\"../../src/icons/align-vertical-distribute-start.ts\",\"../../src/icons/align-vertical-justify-center.ts\",\"../../src/icons/align-vertical-justify-end.ts\",\"../../src/icons/align-vertical-justify-start.ts\",\"../../src/icons/align-vertical-space-around.ts\",\"../../src/icons/align-vertical-space-between.ts\",\"../../src/icons/ambulance.ts\",\"../../src/icons/ampersand.ts\",\"../../src/icons/ampersands.ts\",\"../../src/icons/amphora.ts\",\"../../src/icons/anchor...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/cctv.js.map:\n Line 1: {\"version\":3,\"file\":\"cctv.js\",\"sources\":[\"../../../src/icons/cctv.ts\"],\"sourcesContent\":[\"import createLucideIcon from '../createLucideIcon';\\nimport { IconNode } from '../types';\\n\\nexport const __iconNode: IconNode = [\\n [\\n 'path',\\n {\\n d: 'M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-1.708.134l-2.124-2.97',\\n key: 'ir91b5',\\n },\\n ],\\n [\\n 'path',\\n {\\n d: 'M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z',\\n key: 'jlp8i1',\\n },\\n ],\\n ['path', { d: 'M2 19h3.76a2 2 0 0 0 1.8-1.1L9 15', key: '19bib8' }],\\n ['path', { d: 'M2 21v-4', key: 'l40lih' }],\\n ['path', { d: 'M7 9h.01', key: '19b3jx' }],\\n];\\n\\n/**\\n * @component @name Cctv\\n * @description Lucide SVG icon component, renders SVG Element with children.\\n *\\n * @preview  - https://lucide.dev/icons/cctv\\n * @see https://lucide.dev/guide/packages/lucide-react - Documentation\\n *\\n * @param {Object} props - Lucide icons props and any valid SVG attribute\\n * @returns {JSX.Element} JSX Element\\n *\\n */\\nconst Cctv = createLucideIcon('cctv', __iconNode)...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/beer-off.js.map:\n Line 1: {\"version\":3,\"file\":\"beer-off.js\",\"sources\":[\"../../../src/icons/beer-off.ts\"],\"sourcesContent\":[\"import createLucideIcon from '../createLucideIcon';\\nimport { IconNode } from '../types';\\n\\nexport const __iconNode: IconNode = [\\n ['path', { d: 'M13 13v5', key: 'igwfh0' }],\\n ['path', { d: 'M17 11.47V8', key: '16yw0g' }],\\n ['path', { d: 'M17 11h1a3 3 0 0 1 2.745 4.211', key: '1xbt65' }],\\n ['path', { d: 'm2 2 20 20', key: '1ooewy' }],\\n ['path', { d: 'M5 8v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3', key: 'c55o3e' }],\\n [\\n 'path',\\n { d: 'M7.536 7.535C6.766 7.649 6.154 8 5.5 8a2.5 2.5 0 0 1-1.768-4.268', key: '1ydug7' },\\n ],\\n [\\n 'path',\\n {\\n d: 'M8.727 3.204C9.306 2.767 9.885 2 11 2c1.56 0 2 1.5 3 1.5s1.72-.5 2.5-.5a1 1 0 1 1 0 5c-.78 0-1.5-.5-2.5-.5a3.149 3.149 0 0 0-.842.12',\\n key: 'q81o7q',\\n },\\n ],\\n ['path', { d: 'M9 14.6V18', key: '20ek98' }],\\n];\\n\\n/**\\n * @component @name BeerOff\\n * @description Lucide SVG icon component, renders SVG Element with children.\\n *\\n * @preview  - https://lucide.de...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/spotlight.js:\n Line 17: d: \"M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/radiation.js:\n Line 15: d: \"M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/package-2.js:\n Line 15: d: \"M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/umd/lucide-react.min.js:\n Line 8: (function(e,y){typeof exports==\"object\"&&typeof module<\"u\"?y(exports,require(\"react\")):typeof define==\"function\"&&define.amd?define([\"exports\",\"react\"],y):(e=typeof globalThis<\"u\"?globalThis:e||self,y(e.LucideReact={},e.react))})(this,function(e,y){\"use strict\";const qv=n=>n.replace(/([a-z0-9])([A-Z])/g,\"$1-$2\").toLowerCase(),Sv=n=>n.replace(/^([A-Z])|[\\s-_]+(\\w)/g,(a,k,l)=>l?l.toUpperCase():k.toLowerCase()),sv=n=>{const a=Sv(n);return a.charAt(0).toUpperCase()+a.slice(1)},Cv=(...n)=>n.filter((a,k,l)=>!!a&&a.trim()!==\"\"&&l.indexOf(a)===k).join(\" \").trim(),zv=n=>{for(const a in n)if(a.startsWith(\"aria-\")||a===\"role\"||a===\"title\")return!0};var Av={xmlns:\"http://www.w3.org/2000/svg\",width:24,height:24,viewBox:\"0 0 24 24\",fill:\"none\",stroke:\"currentColor\",strokeWidth:2,strokeLinecap:\"round\",strokeLinejoin:\"round\"};const Iv=y.forwardRef(({color:n=\"currentColor\",size:a=24,strokeWidth:k=2,absoluteStrokeWidth:l,className:fv=\"\",children:c0,iconNode:jv,...bv},Vv)=>y.createElement(\"svg\",{ref:Vv,...Av,width:a,height:a,stroke:n,strokeWidth:l?Number(k)*24/Number(a):k,className:Cv(\"lucide\",fv),...!c0&&!zv(bv)&&{\"aria-hidden\":\"true\"},...bv},[...jv.map(([Pv,Bv])=>y.createElement(Pv,Bv)),...Array.isArray(c0)?c0:[c0]])),c=(n,a)=>{const k=y.forwardRef(({className:l,...fv},c0)=>y.createElement(Iv,{ref:c0,iconNode:a,className:Cv(`lucide-${qv(sv(n))}`,`lucide-${n}`,l),...fv}));return k.displayName=sv(n),k},a0=c(\"a-arrow-down\",[[\"path\",{d:\"m14 12 4 4 4-4\",key:\"buelq4\"}],[\"path\",{d:\"M18 16V7\",key:\"ty0viw\"}],[\"path\",{d:\"m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16\",key:\"d5nyq2\"}],[\"path\",{d:\"M3.304 13h6.392\",key:\"1q3zxz\"}]]),n0=c(\"a-arrow-up\",[[\"path\",{d:\"m14 11 4-4 4 4\",key:\"1pu57t\"}],[\"path\",{d:\"M18 16V7\",key:\"ty0viw\"}],[\"path\",{d:\"m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16\",key:\"d5nyq2\"}],[\"path\",{d:\"M3.304 13h6.392\",key:\"1q3zxz\"}]]),d0=c(\"a-large-small\",[[\"path\",{d:\"m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16\",key:\"xik6mr\"}],[\"path\",{d:\"M15.697 14h5.606\",key:\"1stdlc\"}],[\"path\",{d:\"m2 16 4...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/umd/lucide-react.js:\n Line 1583: [\"path\", { d: \"M17 11h1a3 3 0 0 1 2.745 4.211\", key: \"1xbt65\" }],\n Line 3315: d: \"M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z\",\n Line 11248: d: \"M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z\",\n Line 12467: d: \"M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z\",\n Line 14418: d: \"M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/cjs/lucide-react.js:\n Line 1581: [\"path\", { d: \"M17 11h1a3 3 0 0 1 2.745 4.211\", key: \"1xbt65\" }],\n Line 3313: d: \"M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z\",\n Line 11246: d: \"M16.76 3a2 2 0 0 1 1.8 1.1l2.23 4.479a2 2 0 0 1 .21.891V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9.472a2 2 0 0 1 .211-.894L5.45 4.1A2 2 0 0 1 7.24 3z\",\n Line 12465: d: \"M14 15.4641a4 4 0 0 1-4 0L7.52786 19.74597 A 1 1 0 0 0 7.99303 21.16211 10 10 0 0 0 16.00697 21.16211 1 1 0 0 0 16.47214 19.74597z\",\n Line 14416: d: \"M7.61 6.3a3 3 0 0 0-3.92 1.3l-1.38 2.79a3 3 0 0 0 1.3 3.91l6.89 3.597a1 1 0 0 0 1.342-.447l3.106-6.211a1 1 0 0 0-.447-1.341z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/ZA.js:\n Line 1: module.exports={C:{\"4\":0.00303,\"5\":0.01514,\"34\":0.00303,\"52\":0.00605,\"59\":0.00303,\"78\":0.00303,\"115\":0.03632,\"124\":0.00303,\"128\":0.00303,\"132\":0.00303,\"133\":0.00303,\"136\":0.00303,\"137\":0.00303,\"140\":0.01514,\"141\":0.00303,\"142\":0.00605,\"143\":0.00303,\"144\":0.00908,\"145\":0.15438,\"146\":0.21492,\"147\":0.00303,_:\"2 3 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 125 126 127 129 130 131 134 135 138 139 148 149 3.5 3.6\"},D:{\"39\":0.00605,\"40\":0.00605,\"41\":0.00605,\"42\":0.00605,\"43\":0.00605,\"44\":0.00605,\"45\":0.00605,\"46\":0.00605,\"47\":0.00605,\"48\":0.00605,\"49\":0.00605,\"50\":0.00605,\"51\":0.00605,\"52\":0.07265,\"53\":0.00605,\"54\":0.00605,\"55\":0.00908,\"56\":0.00908,\"57\":0.00605,\"58\":0.00605,\"59\":0.00605,\"60\":0.00605,\"65\":0.00303,\"66\":0.00605,\"69\":0.01816,\"70\":0.00605,\"74\":0.00303,\"75\":0.00303,\"78\":0.00303,\"79\":0.00908,\"81\":0.00303,\"83\":0.00303,\"86\":0.00303,\"87\":0.00908,\"88\":0.01211,\"90\":0.00303,\"91\":0.00303,\"94\":0.00303,\"98\":0.37838,\"100\":0.00303,\"101\":0.00303,\"102\":0.00303,\"103\":0.06054,\"104\":0.05449,\"105\":0.05449,\"106\":0.05449,\"107\":0.05449,\"108\":0.05751,\"109\":0.31784,\"110\":0.05449,\"111\":0.08476,\"112\":2.3762,\"114\":0.0333,\"116\":0.12411,\"117\":0.06054,\"118\":0.00303,\"119\":0.01514,\"120\":0.06054,\"121\":0.00605,\"122\":0.03027,\"123\":0.00303,\"124\":0.06357,\"125\":0.11503,\"126\":1.02313,\"127\":0.00303,\"128\":0.01816,\"129\":0.00303,\"130\":0.02422,\"131\":0.12713,\"132\":0.02724,\"133\":0.13622,\"134\":0.00908,\"135\":0.01514,\"136\":0.02119,\"137\":0.01514,\"138\":0.08778,\"139\":0.1907,\"140\":0.05751,\"141\":0.16043,\"142\":3.15716,\"143\":3.50829,\"144\":0.00303,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 63 64 67 68 71 72 73 76 77 80 84 85 89 92 93 95 96 97 9...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/WS.js:\n Line 1: module.exports={C:{\"102\":0.00808,\"115\":0.00808,\"132\":0.04038,\"142\":0.14941,\"144\":0.09287,\"145\":0.06461,\"146\":0.10903,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 133 134 135 136 137 138 139 140 141 143 147 148 149 3.5 3.6\"},D:{\"69\":0.00808,\"93\":0.0323,\"97\":0.0323,\"98\":0.04038,\"108\":0.01615,\"109\":0.39572,\"111\":0.00808,\"115\":0.02423,\"116\":0.00808,\"125\":0.01615,\"126\":0.10095,\"127\":0.0323,\"128\":0.0323,\"131\":0.05653,\"132\":0.04038,\"134\":0.01615,\"135\":0.0323,\"136\":0.00808,\"137\":0.15748,\"138\":2.657,\"139\":0.00808,\"140\":0.17363,\"141\":0.47245,\"142\":7.39762,\"143\":8.31424,\"144\":0.00808,\"145\":0.02423,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 70 71 72 73 74 75 76 77 78 79 80 81 83 84 85 86 87 88 89 90 91 92 94 95 96 99 100 101 102 103 104 105 106 107 110 112 113 114 117 118 119 120 121 122 123 124 129 130 133 146\"},F:{\"92\":0.00808,\"124\":0.25036,\"125\":0.07268,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"122\":0.00808,\"128\":0.00808,\"131\":0.00808,\"132\":0.04038,\"135\":0.04038,\"137\":0.01615,\"138\":0.15748,\"139\":0.02423,\"140\":0.05653,\"141\":0.00808,\"142\":2.45107,\"143\":3.57767,_:\"12 13 14 15 16 17 18 79 80 81 83 84 85 86 87 88 89 90...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/VN.js:\n Line 1: module.exports={C:{\"5\":0.00341,\"50\":0.00341,\"51\":0.00341,\"52\":0.00682,\"53\":0.00341,\"54\":0.00682,\"55\":0.00682,\"56\":0.00341,\"57\":0.00682,\"58\":0.00341,\"59\":0.00682,\"60\":0.00341,\"61\":0.00341,\"62\":0.00341,\"63\":0.00341,\"64\":0.00341,\"65\":0.00341,\"66\":0.00682,\"67\":0.00341,\"75\":0.00341,\"89\":0.00341,\"113\":0.00341,\"115\":0.03408,\"117\":0.00341,\"118\":0.00341,\"125\":0.00341,\"127\":0.00341,\"128\":0.00682,\"131\":0.02045,\"136\":0.01022,\"137\":0.00341,\"138\":0.00341,\"140\":0.00341,\"143\":0.00341,\"144\":0.00341,\"145\":0.16018,\"146\":0.22152,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 68 69 70 71 72 73 74 76 77 78 79 80 81 82 83 84 85 86 87 88 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 114 116 119 120 121 122 123 124 126 129 130 132 133 134 135 139 141 142 147 148 149 3.5 3.6\"},D:{\"34\":0.01363,\"38\":0.02386,\"39\":0.00682,\"40\":0.00682,\"41\":0.00682,\"42\":0.00682,\"43\":0.00682,\"44\":0.00682,\"45\":0.00682,\"46\":0.00682,\"47\":0.00682,\"48\":0.01022,\"49\":0.00682,\"50\":0.00682,\"51\":0.00682,\"52\":0.00682,\"53\":0.00682,\"54\":0.00682,\"55\":0.00682,\"56\":0.00682,\"57\":0.01022,\"58\":0.00682,\"59\":0.00682,\"60\":0.01363,\"61\":0.00682,\"62\":0.01022,\"63\":0.01022,\"64\":0.01022,\"65\":0.00682,\"66\":0.01363,\"67\":0.00682,\"68\":0.01704,\"69\":0.01022,\"70\":0.01022,\"71\":0.00682,\"72\":0.00341,\"73\":0.00341,\"74\":0.00341,\"75\":0.00341,\"76\":0.00341,\"77\":0.00682,\"79\":0.03749,\"81\":0.00341,\"85\":0.00682,\"87\":0.0443,\"89\":0.00341,\"90\":0.00341,\"91\":0.00682,\"92\":0.00341,\"99\":0.00341,\"100\":0.02386,\"101\":0.00682,\"102\":0.01022,\"103\":0.24197,\"104\":0.2147,\"105\":0.2113,\"106\":0.20107,\"107\":0.22152,\"108\":0.20789,\"109\":0.60662,\"110\":0.19426,\"111\":0.20107,\"112\":0.2113,\"113\":0.00341,\"114\":0.01022,\"115\":0.01022,\"116\":0.40555,\"117\":0.19426,\"118\":0.00341,\"119\":0.01704,\"120\":0.24538,\"121\":0.03067,\"122\":1.3189,\"123\":0.01022,\"124\":0.28286,\"125\":1.12805,\"126\":2.49125,\"127\":0.0409,\"128\":0.09883,\"129\":0.0443,\"130\":0.02726,\"131\":0.46349,\"132\":0.03067,\"133...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/VE.js:\n Line 1: module.exports={C:{\"4\":0.71113,\"5\":0.1323,\"52\":0.01654,\"115\":0.20673,\"128\":0.00827,\"134\":0.00827,\"139\":0.00827,\"140\":0.1075,\"142\":0.00827,\"143\":0.00827,\"144\":0.00827,\"145\":0.29768,\"146\":0.43826,_:\"2 3 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 129 130 131 132 133 135 136 137 138 141 147 148 149 3.5 3.6\"},D:{\"49\":0.00827,\"65\":0.00827,\"68\":0.00827,\"69\":0.14057,\"71\":0.00827,\"72\":0.00827,\"73\":0.01654,\"75\":0.00827,\"76\":0.00827,\"79\":0.00827,\"81\":0.00827,\"85\":0.01654,\"87\":0.00827,\"90\":0.00827,\"91\":0.00827,\"92\":0.00827,\"93\":0.00827,\"97\":0.01654,\"98\":0.00827,\"99\":0.01654,\"101\":0.00827,\"102\":0.00827,\"103\":0.71113,\"104\":0.71113,\"105\":0.6946,\"106\":0.70287,\"107\":0.6946,\"108\":0.6946,\"109\":1.95975,\"110\":0.68633,\"111\":0.8269,\"112\":33.69618,\"114\":0.04135,\"116\":1.43054,\"117\":0.6946,\"118\":0.00827,\"119\":0.00827,\"120\":0.70287,\"121\":0.00827,\"122\":0.25634,\"123\":0.00827,\"124\":0.70287,\"125\":0.52095,\"126\":12.92445,\"127\":0.00827,\"128\":0.02481,\"129\":0.01654,\"130\":0.02481,\"131\":1.414,\"132\":0.16538,\"133\":1.414,\"134\":0.02481,\"135\":0.01654,\"136\":0.03308,\"137\":0.09923,\"138\":0.08269,\"139\":0.07442,\"140\":0.04135,\"141\":0.1075,\"142\":3.18357,\"143\":6.2431,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 66 67 70 74 77 78 80 83 84 86 88 89 94 95 96 100 113 115 144 145 146\"},F:{\"54\":0.00827,\"55\":0.00827,\"56\":0.00827,\"93\":0.03308,\"95\":0.03308,\"123\":0.00827,\"124\":0.86825,\"125\":0.26461,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 57 58 60 62 63 64 65 66 67 68 69 70 71 7...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/VC.js:\n Line 1: module.exports={C:{\"5\":0.16599,\"97\":0.00503,\"115\":0.03521,\"140\":0.00503,\"143\":0.00503,\"144\":0.00503,\"145\":1.09151,\"146\":1.84098,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 141 142 147 148 149 3.5 3.6\"},D:{\"55\":0.01006,\"63\":0.00503,\"65\":0.02012,\"69\":0.19617,\"79\":0.01006,\"85\":0.05533,\"87\":0.25653,\"88\":0.00503,\"94\":0.06539,\"102\":0.00503,\"103\":0.08048,\"105\":0.00503,\"106\":0.00503,\"108\":0.00503,\"109\":0.4527,\"110\":0.02515,\"111\":0.14587,\"114\":0.00503,\"116\":0.02515,\"119\":0.02012,\"120\":0.00503,\"122\":0.01006,\"124\":0.02515,\"125\":1.54421,\"126\":0.06539,\"127\":0.02012,\"128\":0.02012,\"129\":0.02012,\"131\":0.11569,\"132\":0.14587,\"133\":0.03018,\"134\":0.02515,\"135\":0.04527,\"136\":0.17605,\"137\":0.03018,\"138\":0.08551,\"139\":0.24144,\"140\":0.10563,\"141\":1.85607,\"142\":7.67075,\"143\":11.12133,\"144\":0.02012,\"145\":0.03018,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 56 57 58 59 60 61 62 64 66 67 68 70 71 72 73 74 75 76 77 78 80 81 83 84 86 89 90 91 92 93 95 96 97 98 99 100 101 104 107 112 113 115 117 118 121 123 130 146\"},F:{\"63\":0.01509,\"93\":0.02012,\"113\":0.00503,\"122\":0.00503,\"124\":0.8048,\"125\":0.27665,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 114 115 116 117 118 119 120 121 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"14\":0.01006,\"17\":...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/UY.js:\n Line 1: module.exports={C:{\"5\":0.12629,\"52\":0.00702,\"83\":0.01403,\"113\":0.01403,\"115\":0.09822,\"121\":0.00702,\"128\":0.03508,\"134\":0.00702,\"136\":0.01403,\"138\":0.00702,\"139\":0.01403,\"140\":0.01403,\"143\":0.00702,\"144\":0.02105,\"145\":0.30169,\"146\":0.54725,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 114 116 117 118 119 120 122 123 124 125 126 127 129 130 131 132 133 135 137 141 142 147 148 149 3.5 3.6\"},D:{\"49\":0.01403,\"68\":0.00702,\"69\":0.12629,\"70\":0.00702,\"74\":0.00702,\"79\":0.00702,\"86\":0.02105,\"87\":0.00702,\"90\":0.00702,\"95\":0.00702,\"97\":0.00702,\"98\":0.00702,\"99\":0.00702,\"100\":0.00702,\"103\":0.47709,\"104\":0.4841,\"105\":0.4841,\"106\":0.47709,\"107\":0.47007,\"108\":0.47709,\"109\":0.86998,\"110\":0.4841,\"111\":0.60338,\"112\":21.14622,\"114\":0.00702,\"116\":0.97522,\"117\":0.47709,\"119\":0.01403,\"120\":0.4841,\"122\":0.14032,\"123\":0.00702,\"124\":0.49112,\"125\":0.88402,\"126\":8.4613,\"127\":0.02105,\"128\":0.01403,\"129\":0.01403,\"130\":0.01403,\"131\":0.98926,\"132\":0.1333,\"133\":0.98224,\"134\":0.03508,\"135\":0.03508,\"136\":0.01403,\"137\":0.02105,\"138\":0.07718,\"139\":0.07016,\"140\":0.08419,\"141\":0.14032,\"142\":6.63012,\"143\":12.30606,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 71 72 73 75 76 77 78 80 81 83 84 85 88 89 91 92 93 94 96 101 102 113 115 118 121 144 145 146\"},F:{\"56\":0.00702,\"93\":0.00702,\"95\":0.01403,\"123\":0.00702,\"124\":1.23482,\"125\":0.33677,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 96 97 98 99 100 10...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/UA.js:\n Line 1: module.exports={C:{\"5\":0.03908,\"52\":0.03908,\"60\":0.00651,\"68\":0.00651,\"74\":0.00651,\"77\":0.00651,\"92\":0.02606,\"102\":0.00651,\"103\":0.02606,\"110\":0.00651,\"115\":0.3713,\"120\":0.00651,\"127\":0.00651,\"128\":0.01303,\"133\":0.01303,\"134\":0.01303,\"135\":0.02606,\"136\":0.02606,\"137\":0.00651,\"138\":0.01303,\"139\":0.00651,\"140\":0.09771,\"141\":0.00651,\"142\":0.01303,\"143\":0.00651,\"144\":0.0456,\"145\":0.6514,\"146\":1.08132,\"147\":0.00651,\"148\":0.00651,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 61 62 63 64 65 66 67 69 70 71 72 73 75 76 78 79 80 81 82 83 84 85 86 87 88 89 90 91 93 94 95 96 97 98 99 100 101 104 105 106 107 108 109 111 112 113 114 116 117 118 119 121 122 123 124 125 126 129 130 131 132 149 3.5 3.6\"},D:{\"26\":0.00651,\"39\":0.03257,\"40\":0.03257,\"41\":0.03257,\"42\":0.03257,\"43\":0.03257,\"44\":0.03257,\"45\":0.03257,\"46\":0.03908,\"47\":0.03257,\"48\":0.03908,\"49\":0.05211,\"50\":0.03257,\"51\":0.03257,\"52\":0.03257,\"53\":0.03257,\"54\":0.03908,\"55\":0.03908,\"56\":0.03908,\"57\":0.03908,\"58\":0.03257,\"59\":0.03257,\"60\":0.03257,\"61\":0.00651,\"69\":0.03908,\"79\":0.01954,\"81\":0.00651,\"83\":0.00651,\"85\":0.01303,\"86\":0.01303,\"87\":0.01303,\"96\":0.00651,\"98\":0.00651,\"101\":0.01954,\"102\":0.01303,\"103\":0.16936,\"104\":0.29313,\"105\":0.14982,\"106\":0.16936,\"107\":0.15634,\"108\":0.15634,\"109\":2.5144,\"110\":0.14982,\"111\":0.18891,\"112\":7.99268,\"114\":0.01303,\"115\":0.00651,\"116\":0.31267,\"117\":0.14982,\"118\":0.02606,\"119\":0.01954,\"120\":0.2345,\"121\":0.02606,\"122\":0.11074,\"123\":0.01303,\"124\":0.17588,\"125\":0.44295,\"126\":2.38412,\"127\":0.05863,\"128\":0.05211,\"129\":0.01954,\"130\":0.03257,\"131\":0.48204,\"132\":0.09771,\"133\":0.36478,\"134\":0.08468,\"135\":0.48204,\"136\":0.0912,\"137\":0.07817,\"138\":0.22799,\"139\":0.82728,\"140\":0.2345,\"141\":0.55369,\"142\":9.08703,\"143\":17.43146,\"144\":0.01303,\"145\":0.01303,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 27 28 29 30 31 32 33 34 35 36 37 38 62 63 64 65 66 67 68 70 71 72 73 74 75 76...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/TW.js:\n Line 1: module.exports={C:{\"14\":0.00423,\"52\":0.02958,\"78\":0.00423,\"112\":0.00423,\"114\":0.00423,\"115\":0.1014,\"133\":0.00423,\"135\":0.00423,\"136\":0.00423,\"139\":0.00423,\"140\":0.01268,\"141\":0.00423,\"142\":0.00423,\"143\":0.00845,\"144\":0.0169,\"145\":0.3718,\"146\":0.61263,\"147\":0.00423,_:\"2 3 4 5 6 7 8 9 10 11 12 13 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 113 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 134 137 138 148 149 3.5 3.6\"},D:{\"11\":0.00423,\"49\":0.00423,\"61\":0.00423,\"65\":0.00423,\"73\":0.00423,\"75\":0.00423,\"78\":0.00423,\"79\":0.02535,\"80\":0.00423,\"81\":0.09718,\"85\":0.00845,\"86\":0.00845,\"87\":0.02113,\"89\":0.00423,\"90\":0.00423,\"91\":0.00423,\"94\":0.00423,\"95\":0.00423,\"97\":0.00423,\"98\":0.0169,\"100\":0.00423,\"101\":0.00845,\"102\":0.00423,\"103\":0.0507,\"104\":0.09718,\"105\":0.04225,\"106\":0.04225,\"107\":0.04648,\"108\":0.05493,\"109\":1.12385,\"110\":0.04648,\"111\":0.04225,\"112\":0.04225,\"113\":0.00423,\"114\":0.0169,\"115\":0.00845,\"116\":0.1183,\"117\":0.0507,\"118\":0.0169,\"119\":0.04225,\"120\":0.1183,\"121\":0.0338,\"122\":0.03803,\"123\":0.0169,\"124\":0.07183,\"125\":1.16188,\"126\":0.02535,\"127\":0.02958,\"128\":0.05493,\"129\":0.02535,\"130\":0.0676,\"131\":0.16055,\"132\":0.04648,\"133\":0.12675,\"134\":0.0507,\"135\":0.04648,\"136\":0.04225,\"137\":0.05493,\"138\":0.18168,\"139\":0.2535,\"140\":0.21125,\"141\":0.3042,\"142\":8.7373,\"143\":13.6045,\"144\":0.0169,\"145\":0.02113,_:\"4 5 6 7 8 9 10 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 62 63 64 66 67 68 69 70 71 72 74 76 77 83 84 88 92 93 96 99 146\"},F:{\"46\":0.00845,\"89\":0.00423,\"92\":0.00423,\"93\":0.05493,\"94\":0.00845,\"95\":0.0169,\"123\":0.00423,\"124\":0.07605,\"125\":0.04225,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 3...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/TN.js:\n Line 1: module.exports={C:{\"5\":0.06287,\"52\":0.01143,\"78\":0.00572,\"108\":0.00572,\"115\":0.1143,\"124\":0.00572,\"128\":0.00572,\"134\":0.00572,\"136\":0.00572,\"140\":0.01715,\"143\":0.00572,\"144\":0.01143,\"145\":0.30861,\"146\":0.50292,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 109 110 111 112 113 114 116 117 118 119 120 121 122 123 125 126 127 129 130 131 132 133 135 137 138 139 141 142 147 148 149 3.5 3.6\"},D:{\"39\":0.00572,\"47\":0.00572,\"48\":0.01143,\"49\":0.00572,\"50\":0.00572,\"56\":0.02286,\"62\":0.00572,\"63\":0.00572,\"64\":0.00572,\"65\":0.01715,\"66\":0.00572,\"68\":0.01143,\"69\":0.06858,\"70\":0.01143,\"71\":0.00572,\"72\":0.00572,\"73\":0.01143,\"74\":0.00572,\"75\":0.00572,\"78\":0.00572,\"79\":0.01715,\"81\":0.00572,\"83\":0.01143,\"85\":0.01143,\"86\":0.01143,\"87\":0.03429,\"88\":0.00572,\"89\":0.00572,\"90\":0.00572,\"91\":0.00572,\"95\":0.00572,\"98\":0.01143,\"99\":0.00572,\"102\":0.01715,\"103\":0.3029,\"104\":0.30861,\"105\":0.28575,\"106\":0.3029,\"107\":0.29718,\"108\":0.29718,\"109\":2.02311,\"110\":0.31433,\"111\":0.36005,\"112\":14.00747,\"114\":0.01715,\"116\":0.61151,\"117\":0.29147,\"119\":0.04001,\"120\":0.30861,\"121\":0.01715,\"122\":0.09144,\"123\":0.01143,\"124\":0.30861,\"125\":0.36005,\"126\":4.62915,\"127\":0.01715,\"128\":0.03429,\"129\":0.01715,\"130\":0.01143,\"131\":0.66294,\"132\":0.09716,\"133\":0.60579,\"134\":0.04001,\"135\":0.04001,\"136\":0.04001,\"137\":0.05144,\"138\":0.1143,\"139\":0.37719,\"140\":0.08573,\"141\":0.17717,\"142\":5.73786,\"143\":9.4469,\"144\":0.00572,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 40 41 42 43 44 45 46 51 52 53 54 55 57 58 59 60 61 67 76 77 80 84 92 93 94 96 97 100 101 113 115 118 145 146\"},F:{\"40\":0.00572,\"46\":0.00572,\"79\":0.00572,\"82\":0.00572,\"85\":0.00572,\"93\":0.02858,\"95\":0.0743,\"122\":0.00572,\"123\":0.01715,\"124\":1.49733,\"125\":...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/TM.js:\n Line 1: module.exports={C:{\"60\":0.02908,\"68\":0.01163,\"84\":0.01163,\"115\":0.08141,\"125\":0.20934,\"135\":0.05815,\"141\":0.08723,\"143\":0.02908,\"145\":0.15119,\"146\":0.86644,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 61 62 63 64 65 66 67 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 126 127 128 129 130 131 132 133 134 136 137 138 139 140 142 144 147 148 149 3.5 3.6\"},D:{\"70\":0.11049,\"71\":0.05234,\"74\":0.01163,\"76\":1.52353,\"81\":0.04071,\"86\":0.01163,\"89\":0.01163,\"103\":0.01745,\"104\":0.04071,\"109\":3.19244,\"111\":0.01163,\"116\":0.01163,\"119\":0.04071,\"120\":0.02908,\"123\":0.01163,\"125\":0.15701,\"126\":0.04071,\"127\":0.04071,\"130\":0.08723,\"131\":0.01163,\"134\":0.04071,\"135\":0.01745,\"137\":0.01745,\"138\":0.05234,\"139\":0.04071,\"140\":0.08723,\"141\":0.81992,\"142\":8.64109,\"143\":12.97908,\"144\":0.01163,\"145\":0.05234,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 72 73 75 77 78 79 80 83 84 85 87 88 90 91 92 93 94 95 96 97 98 99 100 101 102 105 106 107 108 110 112 113 114 115 117 118 121 122 124 128 129 132 133 136 146\"},F:{\"60\":0.01745,\"79\":0.11049,\"93\":0.05234,\"124\":0.25005,\"125\":0.12793,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"100\":0.01745,\"109\":0.05815,\"110\":0.15701,\"113\":0.01163,\"117\":0.01163,\"133\":0.01163,\"141\":0.01745,\"142\":0.27912,\"143\":2.12248,...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/TG.js:\n Line 1: module.exports={C:{\"5\":0.06544,\"52\":0.00503,\"59\":0.00503,\"60\":0.00503,\"61\":0.00503,\"69\":0.00503,\"72\":0.01007,\"73\":0.00503,\"84\":0.00503,\"114\":0.00503,\"115\":0.2819,\"123\":0.00503,\"127\":0.02014,\"128\":0.00503,\"135\":0.00503,\"139\":0.00503,\"140\":0.06041,\"141\":0.0151,\"142\":0.0151,\"143\":0.02517,\"144\":0.0302,\"145\":0.65945,\"146\":1.42966,\"147\":0.01007,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 62 63 64 65 66 67 68 70 71 74 75 76 77 78 79 80 81 82 83 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 116 117 118 119 120 121 122 124 125 126 129 130 131 132 133 134 136 137 138 148 149 3.5 3.6\"},D:{\"27\":0.00503,\"47\":0.00503,\"48\":0.00503,\"56\":0.00503,\"58\":0.00503,\"63\":0.02014,\"64\":0.00503,\"66\":0.00503,\"69\":0.07551,\"70\":0.01007,\"71\":0.00503,\"72\":0.00503,\"73\":0.0151,\"74\":0.00503,\"75\":0.01007,\"76\":0.0151,\"77\":0.01007,\"79\":0.02517,\"80\":0.00503,\"81\":0.00503,\"83\":0.02014,\"84\":0.02517,\"85\":0.00503,\"86\":0.01007,\"87\":0.04531,\"88\":0.00503,\"89\":0.0151,\"90\":0.0151,\"91\":0.00503,\"92\":0.00503,\"93\":0.02014,\"94\":0.00503,\"95\":0.00503,\"98\":0.04531,\"100\":0.01007,\"101\":0.02014,\"102\":0.0302,\"103\":0.29197,\"104\":0.23156,\"105\":0.2215,\"106\":0.22653,\"107\":0.2366,\"108\":0.21143,\"109\":1.0219,\"110\":0.2366,\"111\":0.34231,\"112\":10.90868,\"114\":0.02517,\"115\":0.02014,\"116\":0.49333,\"117\":0.20639,\"119\":0.06544,\"120\":0.24667,\"122\":0.10068,\"123\":0.00503,\"124\":0.24163,\"125\":0.16612,\"126\":3.0506,\"127\":0.01007,\"128\":0.02517,\"129\":0.02014,\"130\":0.02517,\"131\":0.49837,\"132\":0.12082,\"133\":0.44299,\"134\":0.02517,\"135\":0.03524,\"136\":0.02517,\"137\":0.0302,\"138\":0.2366,\"139\":0.18626,\"140\":0.08558,\"141\":0.16612,\"142\":3.78053,\"143\":8.31617,\"144\":0.0151,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 49 50 51 52 53 54 55 57 59 60 61 62 65 67 68 78 96 97 99 113 118 121 145 146\"},F:{\"40\":0.00503,\"46\":0....\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/SZ.js:\n Line 1: module.exports={C:{\"5\":0.01056,\"78\":0.03168,\"111\":0.08184,\"113\":0.03168,\"115\":0.02904,\"127\":0.00528,\"128\":0.01056,\"140\":0.01848,\"143\":0.00528,\"145\":0.38544,\"146\":0.37488,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 112 114 116 117 118 119 120 121 122 123 124 125 126 129 130 131 132 133 134 135 136 137 138 139 141 142 144 147 148 149 3.5 3.6\"},D:{\"52\":0.00792,\"59\":0.01056,\"61\":0.00264,\"68\":0.02904,\"69\":0.01848,\"70\":0.00264,\"71\":0.01848,\"79\":0.00792,\"83\":0.00528,\"86\":0.00528,\"87\":0.00264,\"95\":0.00264,\"97\":0.00264,\"98\":0.00528,\"100\":0.00528,\"101\":0.00792,\"103\":0.02904,\"104\":0.01848,\"105\":0.00264,\"106\":0.00264,\"107\":0.00528,\"108\":0.00264,\"109\":0.22968,\"111\":0.03432,\"112\":0.06864,\"114\":0.0264,\"115\":0.00264,\"116\":0.01056,\"117\":0.00264,\"118\":0.00528,\"119\":0.00528,\"120\":0.01584,\"124\":0.01584,\"125\":0.07656,\"126\":0.03168,\"127\":0.00528,\"128\":0.04224,\"129\":0.00264,\"130\":0.00528,\"131\":0.06336,\"132\":0.0132,\"133\":0.01584,\"134\":0.01056,\"135\":0.00792,\"136\":0.04752,\"137\":0.066,\"138\":0.4356,\"139\":0.12936,\"140\":0.03696,\"141\":0.15048,\"142\":2.31792,\"143\":3.19968,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 60 62 63 64 65 66 67 72 73 74 75 76 77 78 80 81 84 85 88 89 90 91 92 93 94 96 99 102 110 113 121 122 123 144 145 146\"},F:{\"35\":0.00264,\"37\":0.00264,\"40\":0.00264,\"42\":0.0132,\"45\":0.00264,\"88\":0.00264,\"90\":0.02112,\"92\":0.0132,\"93\":0.14256,\"95\":0.00792,\"114\":0.00264,\"120\":0.00528,\"123\":0.01056,\"124\":0.36168,\"125\":0.17688,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 36 38 39 41 43 44 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/SL.js:\n Line 1: module.exports={C:{\"5\":0.06832,\"38\":0.00342,\"49\":0.00342,\"77\":0.00342,\"94\":0.00683,\"112\":0.01025,\"115\":0.20154,\"127\":0.00342,\"128\":0.02733,\"139\":0.0205,\"140\":0.00342,\"141\":0.00342,\"142\":0.00683,\"143\":0.02733,\"144\":0.01366,\"145\":0.39967,\"146\":0.35868,\"147\":0.01366,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 113 114 116 117 118 119 120 121 122 123 124 125 126 129 130 131 132 133 134 135 136 137 138 148 149 3.5 3.6\"},D:{\"41\":0.00342,\"47\":0.01366,\"48\":0.00683,\"49\":0.00342,\"58\":0.01708,\"59\":0.00342,\"62\":0.00683,\"64\":0.01366,\"65\":0.00342,\"67\":0.01366,\"68\":0.01366,\"69\":0.08198,\"70\":0.01025,\"71\":0.00683,\"73\":0.00683,\"74\":0.03074,\"75\":0.01708,\"77\":0.01025,\"79\":0.09565,\"80\":0.00342,\"81\":0.03758,\"83\":0.00683,\"85\":0.00683,\"86\":0.01025,\"87\":0.00683,\"88\":0.00342,\"89\":0.00683,\"92\":0.00342,\"93\":0.01708,\"94\":0.00683,\"95\":0.00683,\"96\":0.02391,\"97\":0.00342,\"98\":0.00683,\"101\":0.01025,\"102\":0.06149,\"103\":0.0649,\"104\":0.01025,\"105\":0.01025,\"106\":0.0205,\"107\":0.00683,\"108\":0.02733,\"109\":0.10248,\"110\":0.00683,\"111\":0.10931,\"112\":0.01025,\"113\":0.00342,\"114\":0.01366,\"115\":0.00342,\"116\":0.05807,\"117\":0.01025,\"118\":0.01708,\"119\":0.06149,\"120\":0.03416,\"121\":0.00342,\"122\":0.05124,\"123\":0.00342,\"124\":0.0205,\"125\":0.03074,\"126\":0.21179,\"127\":0.01025,\"128\":0.06832,\"129\":0.00683,\"130\":0.03416,\"131\":0.08198,\"132\":0.07857,\"133\":0.05466,\"134\":0.00683,\"135\":0.07857,\"136\":0.03074,\"137\":0.0649,\"138\":0.4475,\"139\":0.10248,\"140\":0.20154,\"141\":0.3416,\"142\":4.08212,\"143\":4.51937,\"144\":0.00683,\"145\":0.01708,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 42 43 44 45 46 50 51 52 53 54 55 56 57 60 61 63 66 72 76 78 84 90 91 99 100 146\"},F:{\"34\":0.00683,\"37\":0.00683,\"64\":0.00683,\"7...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/SG.js:\n Line 1: module.exports={C:{\"78\":0.02413,\"115\":0.02895,\"124\":0.00483,\"125\":0.00483,\"128\":0.00483,\"133\":0.00483,\"135\":0.00483,\"136\":0.00483,\"140\":0.01448,\"142\":0.00483,\"143\":0.00483,\"144\":0.00965,\"145\":0.2509,\"146\":0.39565,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 126 127 129 130 131 132 134 137 138 139 141 147 148 149 3.5 3.6\"},D:{\"39\":0.02413,\"40\":0.02895,\"41\":0.02413,\"42\":0.02413,\"43\":0.02413,\"44\":0.02413,\"45\":0.02413,\"46\":0.02413,\"47\":0.02413,\"48\":0.02413,\"49\":0.02413,\"50\":0.02413,\"51\":0.02413,\"52\":0.02413,\"53\":0.02895,\"54\":0.02413,\"55\":0.02895,\"56\":0.02895,\"57\":0.02895,\"58\":0.02413,\"59\":0.02413,\"60\":0.02413,\"71\":0.00483,\"78\":0.00483,\"79\":0.00483,\"85\":0.26055,\"86\":0.00483,\"87\":0.00483,\"91\":0.01448,\"97\":0.00965,\"98\":0.00483,\"99\":0.00483,\"101\":0.00965,\"103\":0.06755,\"104\":0.06273,\"105\":0.20748,\"106\":0.06273,\"107\":0.06273,\"108\":0.06273,\"109\":0.18335,\"110\":0.0579,\"111\":0.0579,\"112\":0.12545,\"113\":0.00483,\"114\":0.01448,\"115\":0.00965,\"116\":0.13993,\"117\":0.08685,\"118\":0.00483,\"119\":0.00965,\"120\":0.1158,\"121\":0.02895,\"122\":0.02895,\"123\":0.02413,\"124\":0.07238,\"125\":0.06273,\"126\":0.02895,\"127\":0.0386,\"128\":0.08685,\"129\":0.02413,\"130\":0.10615,\"131\":0.20748,\"132\":0.04343,\"133\":0.13993,\"134\":0.43425,\"135\":0.04825,\"136\":0.05308,\"137\":1.5054,\"138\":0.10133,\"139\":21.423,\"140\":0.14475,\"141\":0.41978,\"142\":4.48243,\"143\":6.71158,\"144\":0.01448,\"145\":0.01448,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 63 64 65 66 67 68 69 70 72 73 74 75 76 77 80 81 83 84 88 89 90 92 93 94 95 96 100 102 146\"},F:{\"85\":0.00483,\"92\":0.00965,\"93\":0.18335,\"95\":0.02413,\"114\":0.00483,\"119\":0.00483,\"122\":0.02413,\"123\":0.01448,\"12...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/SC.js:\n Line 1: module.exports={C:{\"5\":0.00353,\"37\":0.00353,\"59\":0.00353,\"60\":0.01058,\"78\":0.00353,\"103\":0.00353,\"114\":0.00353,\"115\":0.03881,\"117\":0.00353,\"118\":0.00353,\"120\":0.00353,\"121\":0.00706,\"123\":0.00353,\"124\":0.00353,\"125\":0.00353,\"126\":0.00353,\"127\":0.00353,\"128\":0.04586,\"129\":0.00353,\"130\":0.00353,\"131\":0.00353,\"132\":0.11995,\"133\":0.01411,\"134\":0.01411,\"135\":0.00353,\"136\":0.00353,\"137\":0.00706,\"138\":0.00353,\"139\":0.01058,\"140\":0.25754,\"141\":0.00353,\"142\":0.01058,\"143\":0.01058,\"144\":0.01411,\"145\":0.1129,\"146\":0.23285,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 104 105 106 107 108 109 110 111 112 113 116 119 122 147 148 149 3.5 3.6\"},D:{\"39\":0.20462,\"40\":0.20815,\"41\":0.21874,\"42\":0.2011,\"43\":0.20462,\"44\":0.20815,\"45\":1.06193,\"46\":0.20815,\"47\":0.2011,\"48\":0.22226,\"49\":0.21168,\"50\":0.2011,\"51\":0.21874,\"52\":0.21521,\"53\":0.21168,\"54\":0.21521,\"55\":0.20815,\"56\":0.21521,\"57\":0.21874,\"58\":0.21168,\"59\":0.21521,\"60\":0.21521,\"61\":0.00353,\"63\":0.00353,\"64\":0.00353,\"65\":0.01058,\"66\":0.01411,\"67\":0.00706,\"68\":0.00353,\"69\":0.01058,\"70\":0.00706,\"71\":0.00353,\"73\":0.00706,\"78\":0.00353,\"81\":0.00353,\"83\":0.01411,\"86\":0.03881,\"87\":0.00353,\"88\":0.00353,\"91\":0.04939,\"92\":0.01058,\"94\":0.00353,\"98\":0.00353,\"100\":0.00353,\"101\":0.02822,\"102\":0.00353,\"103\":0.01058,\"104\":0.04586,\"105\":0.00706,\"106\":0.00706,\"107\":0.03528,\"108\":0.00706,\"109\":0.34927,\"110\":0.00353,\"111\":0.00706,\"112\":0.01411,\"113\":0.01058,\"114\":0.03881,\"115\":0.01058,\"116\":0.57154,\"117\":0.00706,\"118\":0.10937,\"119\":0.03175,\"120\":0.23638,\"121\":0.03175,\"122\":0.02117,\"123\":0.40219,\"124\":0.05645,\"125\":0.25402,\"126\":0.03881,\"127\":0.03528,\"128\":0.11642,\"129\":0.1129,\"130\":0.25754,\"131\":0.19051,\"132\":0.15523,\"133\":0.18346,\"134\":0.15523,\"135\":0.16229,\"136\":0.15523,\"137\":0.21521,\"138\":0.38808,\"139\":0.19757,\"140\"...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/RE.js:\n Line 1: module.exports={C:{\"5\":0.00929,\"78\":0.19036,\"82\":0.00464,\"88\":0.00464,\"102\":0.00929,\"103\":0.00464,\"115\":0.21822,\"120\":0.00929,\"128\":0.02786,\"136\":0.15786,\"137\":0.00464,\"138\":0.00464,\"140\":0.07429,\"141\":0.00929,\"142\":0.00464,\"143\":0.02322,\"144\":0.05572,\"145\":1.17932,\"146\":1.87577,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 83 84 85 86 87 89 90 91 92 93 94 95 96 97 98 99 100 101 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 121 122 123 124 125 126 127 129 130 131 132 133 134 135 139 147 148 149 3.5 3.6\"},D:{\"49\":0.00464,\"69\":0.00464,\"79\":0.02322,\"83\":0.01857,\"85\":0.01393,\"86\":0.00464,\"87\":0.11143,\"88\":0.03714,\"90\":0.00464,\"98\":0.00464,\"103\":0.04179,\"104\":0.05572,\"105\":0.00464,\"106\":0.00464,\"107\":0.00464,\"108\":0.00929,\"109\":0.58038,\"110\":0.01857,\"111\":0.02786,\"112\":0.00464,\"113\":0.00464,\"116\":0.05572,\"117\":0.00464,\"119\":0.0325,\"120\":0.07893,\"121\":0.00929,\"122\":0.05572,\"123\":0.00464,\"124\":0.00929,\"125\":0.10679,\"126\":0.10679,\"127\":0.00929,\"128\":0.06036,\"129\":0.00464,\"130\":0.04179,\"131\":0.12536,\"132\":0.01857,\"133\":0.065,\"134\":0.0325,\"135\":0.04179,\"136\":0.02322,\"137\":0.04179,\"138\":0.28322,\"139\":0.11143,\"140\":0.51073,\"141\":0.29251,\"142\":5.98483,\"143\":12.29466,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 70 71 72 73 74 75 76 77 78 80 81 84 89 91 92 93 94 95 96 97 99 100 101 102 114 115 118 144 145 146\"},F:{\"46\":0.01393,\"93\":0.09286,\"95\":0.00464,\"102\":0.01393,\"119\":0.00464,\"123\":0.03714,\"124\":1.3604,\"125\":1.4904,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/PS.js:\n Line 1: module.exports={C:{\"5\":0.02421,\"115\":0.02421,\"127\":0.00242,\"140\":0.00726,\"142\":0.00242,\"143\":0.00242,\"144\":0.00726,\"145\":0.1041,\"146\":0.17673,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 128 129 130 131 132 133 134 135 136 137 138 139 141 147 148 149 3.5 3.6\"},D:{\"38\":0.00242,\"56\":0.00242,\"66\":0.00484,\"69\":0.02663,\"70\":0.00484,\"72\":0.00242,\"75\":0.00242,\"77\":0.01453,\"78\":0.00242,\"79\":0.00968,\"80\":0.00242,\"83\":0.00484,\"85\":0.00242,\"86\":0.00242,\"87\":0.01211,\"89\":0.00484,\"90\":0.00242,\"91\":0.00242,\"92\":0.00242,\"95\":0.00242,\"97\":0.00484,\"98\":0.00484,\"100\":0.00242,\"101\":0.00242,\"103\":0.07747,\"104\":0.07747,\"105\":0.07505,\"106\":0.07505,\"107\":0.08474,\"108\":0.08716,\"109\":0.27599,\"110\":0.08474,\"111\":0.10168,\"112\":5.42304,\"113\":0.00242,\"114\":0.00484,\"115\":0.00242,\"116\":0.15737,\"117\":0.09684,\"118\":0.00242,\"119\":0.00484,\"120\":0.11137,\"121\":0.00242,\"122\":0.05326,\"123\":0.03632,\"124\":0.08231,\"125\":0.06537,\"126\":1.17661,\"127\":0.01453,\"128\":0.03389,\"129\":0.00726,\"130\":0.01211,\"131\":0.17673,\"132\":0.03389,\"133\":0.16705,\"134\":0.02179,\"135\":0.02179,\"136\":0.02421,\"137\":0.02421,\"138\":0.07021,\"139\":0.06295,\"140\":0.05568,\"141\":0.08231,\"142\":2.75026,\"143\":3.71381,\"145\":0.00242,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 57 58 59 60 61 62 63 64 65 67 68 71 73 74 76 81 84 88 93 94 96 99 102 144 146\"},F:{\"85\":0.00242,\"93\":0.01453,\"95\":0.00242,\"122\":0.00242,\"124\":0.11621,\"125\":0.05568,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 7...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/PL.js:\n Line 1: module.exports={C:{\"47\":0.00621,\"52\":0.01864,\"60\":0.00621,\"78\":0.00621,\"113\":0.00621,\"115\":0.3977,\"120\":0.00621,\"127\":0.00621,\"128\":0.03107,\"130\":0.00621,\"131\":0.00621,\"133\":0.00621,\"134\":0.00621,\"135\":0.00621,\"136\":0.02486,\"137\":0.00621,\"138\":0.00621,\"139\":0.01243,\"140\":0.32934,\"141\":0.01243,\"142\":0.02486,\"143\":0.03107,\"144\":0.10564,\"145\":1.6405,\"146\":2.69688,\"147\":0.01864,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 48 49 50 51 53 54 55 56 57 58 59 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 114 116 117 118 119 121 122 123 124 125 126 129 132 148 149 3.5 3.6\"},D:{\"39\":0.01243,\"40\":0.01243,\"41\":0.01243,\"42\":0.01243,\"43\":0.01243,\"44\":0.01243,\"45\":0.01243,\"46\":0.01864,\"47\":0.01243,\"48\":0.01243,\"49\":0.01864,\"50\":0.01243,\"51\":0.01243,\"52\":0.01243,\"53\":0.01864,\"54\":0.01243,\"55\":0.01243,\"56\":0.01243,\"57\":0.01864,\"58\":0.01243,\"59\":0.01243,\"60\":0.01243,\"79\":0.39148,\"87\":0.01864,\"89\":0.01243,\"90\":0.00621,\"91\":0.00621,\"93\":0.03107,\"99\":0.06214,\"102\":0.00621,\"103\":0.01864,\"104\":0.01243,\"105\":0.00621,\"107\":0.00621,\"108\":0.00621,\"109\":0.73947,\"111\":0.59654,\"112\":0.00621,\"113\":0.00621,\"114\":0.01864,\"115\":0.00621,\"116\":0.0435,\"118\":0.0435,\"119\":0.00621,\"120\":0.0435,\"121\":0.01243,\"122\":0.06214,\"123\":0.32313,\"124\":0.01243,\"125\":0.18642,\"126\":0.0435,\"127\":0.02486,\"128\":0.03728,\"129\":0.01864,\"130\":0.09942,\"131\":0.27342,\"132\":0.32934,\"133\":0.03728,\"134\":0.0435,\"135\":0.04971,\"136\":0.08078,\"137\":0.32313,\"138\":0.12428,\"139\":0.45984,\"140\":0.15535,\"141\":0.2237,\"142\":8.23976,\"143\":17.65397,\"144\":0.07457,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 80 81 83 84 85 86 88 92 94 95 96 97 98 100 101 106 110 117 145 146\"},F:{\"92\":0.00621,\"93\":0.12428,\"95\":0.17399,\"114\":0.00621,\"117\":0....\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/features/picture-in-picture.js:\n Line 1: module.exports={A:{A:{\"2\":\"K D E F A B 1C\"},B:{\"1\":\"0 1 2 3 4 5 6 7 8 Q H R S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I\",\"2\":\"C L M G N O P\"},C:{\"2\":\"9 2C WC J cB K D E F A B C L M G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B XC 7B YC 8B 9B AC BC CC 5C 6C\",\"132\":\"0 1 2 3 4 5 6 7 8 IC JC KC LC MC NC OC Q H R ZC S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I aC PC bC 3C 4C\",\"1090\":\"DC\",\"1412\":\"HC\",\"1668\":\"EC FC GC\"},D:{\"1\":\"0 1 2 3 4 5 6 7 8 GC HC IC JC KC LC MC NC OC Q H R S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I aC PC bC\",\"2\":\"9 J cB K D E F A B C L M G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B XC 7B YC 8B 9B AC BC CC DC EC\",\"2114\":\"FC\"},E:{\"1\":\"M G CD DD ED eC fC SC FD TC gC hC iC jC kC GD UC lC mC nC oC pC HD VC qC rC sC tC ID uC vC wC xC yC JD\",\"2\":\"J cB K D E F 7C cC 8C 9C AD BD\",\"4100\":\"A B C L dC QC RC\"},F:{\"1\":\"0 1 2 3 4 5 6 7 8 JC KC LC MC NC OC Q H R ZC S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z\",\"2\":\"9 F B C G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB KD LD MD ND QC zC OD RC\",\"8196\":\"lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B 7B 8B 9B AC BC CC DC EC FC GC HC IC\"},G:{\"1\":\"gD hD iD eC fC SC jD TC gC hC iC jC kC kD UC lC mC nC oC pC lD VC qC rC sC tC mD uC vC wC xC yC\",\"2\":\"E cC PD 0C QD RD SD TD\",\"4100\":\"UD VD WD XD YD ZD aD bD cD dD eD fD\"},H:{\"2\":\"nD\"},I:{\"2\":\"WC J I oD pD qD rD 0C sD tD\"},J:{\"2\":\"D A\"},K:{\"1\":\"H\",\"2\":\"A B C QC zC RC\"},L:{\"16388\":\"I\"},M:{\"16388\":\"PC\"},N:{\"2\":\"A B\"},O:{\"2\":\"SC\"},P:{\"2\":\"9 J AB BB CB DB EB FB GB HB IB uD vD wD xD yD dC zD 0D 1D 2D 3D TC UC VC 4D\"},Q:{\"2\":\"5D\"},...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/PG.js:\n Line 1: module.exports={C:{\"46\":0.00399,\"75\":0.00399,\"112\":0.00399,\"115\":0.04788,\"127\":0.00399,\"132\":0.00399,\"140\":0.01596,\"142\":0.01197,\"143\":0.00399,\"144\":0.01596,\"145\":0.19152,\"146\":0.21147,\"147\":0.00399,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 113 114 116 117 118 119 120 121 122 123 124 125 126 128 129 130 131 133 134 135 136 137 138 139 141 148 149 3.5 3.6\"},D:{\"11\":0.00399,\"26\":0.00798,\"38\":0.00399,\"43\":0.00399,\"47\":0.00399,\"57\":0.00399,\"61\":0.00399,\"65\":0.00399,\"67\":0.01995,\"69\":0.00399,\"70\":0.00399,\"71\":0.00798,\"78\":0.00798,\"79\":0.01197,\"80\":0.00798,\"86\":0.00399,\"87\":0.01995,\"88\":0.03591,\"89\":0.00399,\"94\":0.03192,\"95\":0.00399,\"99\":0.01197,\"100\":0.00399,\"102\":0.00399,\"103\":0.01596,\"104\":0.06783,\"105\":0.02793,\"106\":0.01596,\"107\":0.00399,\"109\":0.20748,\"110\":0.01596,\"111\":0.01596,\"114\":0.77406,\"116\":0.01197,\"118\":0.00399,\"120\":0.08379,\"121\":0.01596,\"122\":0.01197,\"123\":0.01995,\"124\":0.00399,\"125\":0.0399,\"126\":0.08778,\"127\":0.02394,\"128\":0.21147,\"129\":0.01596,\"130\":0.00399,\"131\":0.06783,\"132\":0.00798,\"133\":0.01995,\"134\":0.01197,\"135\":0.07182,\"136\":0.02793,\"137\":0.04389,\"138\":0.09177,\"139\":0.12369,\"140\":0.08379,\"141\":0.17955,\"142\":3.08826,\"143\":3.54312,\"144\":0.00798,_:\"4 5 6 7 8 9 10 12 13 14 15 16 17 18 19 20 21 22 23 24 25 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 44 45 46 48 49 50 51 52 53 54 55 56 58 59 60 62 63 64 66 68 72 73 74 75 76 77 81 83 84 85 90 91 92 93 96 97 98 101 108 112 113 115 117 119 145 146\"},F:{\"90\":0.01197,\"92\":0.02793,\"93\":0.13167,\"94\":0.00399,\"95\":0.00399,\"108\":0.00399,\"120\":0.00798,\"122\":0.00798,\"123\":0.01596,\"124\":0.21945,\"125\":0.08379,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 5...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/PF.js:\n Line 1: module.exports={C:{\"78\":0.00904,\"102\":0.00226,\"115\":0.13786,\"128\":0.00904,\"130\":0.00226,\"135\":0.00452,\"138\":0.00452,\"140\":0.07006,\"141\":0.0113,\"142\":0.00226,\"143\":0.04294,\"144\":0.00904,\"145\":0.44296,\"146\":0.8588,\"147\":0.00226,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 129 131 132 133 134 136 137 139 148 149 3.5 3.6\"},D:{\"70\":0.02938,\"79\":0.00678,\"87\":0.01808,\"89\":0.00226,\"93\":0.00678,\"101\":0.0113,\"103\":0.02034,\"107\":0.00226,\"108\":0.0113,\"109\":0.17854,\"115\":0.01356,\"116\":0.06328,\"119\":0.00226,\"120\":0.01356,\"122\":0.01356,\"125\":0.00226,\"126\":0.00452,\"127\":0.01808,\"128\":0.07006,\"130\":0.01808,\"131\":0.01582,\"132\":0.01356,\"133\":0.01582,\"134\":0.0226,\"135\":0.00226,\"136\":0.00452,\"137\":0.00226,\"138\":0.06102,\"139\":0.09944,\"140\":0.01808,\"141\":0.1808,\"142\":2.373,\"143\":4.4974,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 71 72 73 74 75 76 77 78 80 81 83 84 85 86 88 90 91 92 94 95 96 97 98 99 100 102 104 105 106 110 111 112 113 114 117 118 121 123 124 129 144 145 146\"},F:{\"46\":0.00678,\"93\":0.03164,\"119\":0.00226,\"123\":0.00226,\"124\":0.17402,\"125\":0.27346,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 120 121 122 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"109\":0.00452,\"126\":0.00452,\"130\":0.00226,\"131\":0.00452,...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/NC.js:\n Line 1: module.exports={C:{\"5\":0.00306,\"53\":0.32721,\"86\":0.01529,\"101\":0.00306,\"102\":0.01223,\"115\":0.06422,\"117\":0.00306,\"126\":0.00306,\"128\":0.10397,\"135\":0.00917,\"136\":0.00612,\"139\":0.10091,\"140\":0.04281,\"142\":0.00612,\"143\":0.00917,\"144\":0.07339,\"145\":1.36998,\"146\":2.20176,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 87 88 89 90 91 92 93 94 95 96 97 98 99 100 103 104 105 106 107 108 109 110 111 112 113 114 116 118 119 120 121 122 123 124 125 127 129 130 131 132 133 134 137 138 141 147 148 149 3.5 3.6\"},D:{\"47\":0.00306,\"69\":0.00612,\"79\":0.02141,\"84\":0.00306,\"87\":0.00917,\"92\":0.00306,\"93\":0.00306,\"94\":0.00612,\"96\":0.00612,\"98\":0.00306,\"99\":0.00306,\"103\":0.00612,\"104\":0.00306,\"105\":0.00306,\"106\":0.00306,\"107\":0.00306,\"109\":0.40671,\"110\":0.00306,\"111\":0.02141,\"112\":0.00306,\"116\":0.03058,\"119\":0.00612,\"120\":0.00917,\"121\":0.00306,\"122\":0.00612,\"123\":0.00612,\"124\":0.00917,\"125\":0.09174,\"126\":0.04893,\"128\":0.0948,\"129\":0.00306,\"131\":0.04587,\"132\":0.01529,\"133\":0.00306,\"134\":0.00306,\"135\":0.00917,\"136\":0.00306,\"137\":0.00612,\"138\":0.04587,\"139\":0.86541,\"140\":0.04893,\"141\":0.10703,\"142\":3.48306,\"143\":6.90191,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 70 71 72 73 74 75 76 77 78 80 81 83 85 86 88 89 90 91 95 97 100 101 102 108 113 114 115 117 118 127 130 144 145 146\"},F:{\"93\":0.00917,\"102\":0.00306,\"122\":0.00306,\"124\":0.77673,\"125\":0.6116,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 103 104 105 106 107 108 109 110 111 112 113 114 115 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/MU.js:\n Line 1: module.exports={C:{\"5\":0.0106,\"78\":0.00353,\"80\":0.00353,\"102\":0.00706,\"115\":0.0989,\"118\":0.00353,\"119\":0.00706,\"120\":0.00706,\"127\":0.00353,\"134\":0.00706,\"135\":0.00353,\"138\":0.00353,\"139\":0.00353,\"140\":0.04945,\"141\":0.0106,\"143\":0.01766,\"144\":0.01766,\"145\":0.3638,\"146\":0.59691,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 103 104 105 106 107 108 109 110 111 112 113 114 116 117 121 122 123 124 125 126 128 129 130 131 132 133 136 137 142 147 148 149 3.5 3.6\"},D:{\"50\":0.00353,\"56\":0.00353,\"68\":0.00353,\"69\":0.01413,\"78\":0.00706,\"79\":0.02472,\"80\":0.00353,\"86\":0.00353,\"87\":0.0106,\"88\":0.00706,\"90\":0.00353,\"91\":0.01766,\"94\":0.00353,\"98\":0.00353,\"99\":0.00353,\"103\":0.02826,\"104\":0.01766,\"105\":0.01766,\"106\":0.01413,\"107\":0.02119,\"108\":0.01766,\"109\":0.39912,\"110\":0.01413,\"111\":0.03885,\"112\":1.59646,\"114\":0.01766,\"115\":0.00353,\"116\":0.05651,\"117\":0.02826,\"118\":0.0106,\"119\":0.01413,\"120\":0.06358,\"121\":0.04238,\"122\":0.05298,\"123\":0.00706,\"124\":0.06358,\"125\":0.24018,\"126\":0.26843,\"127\":0.03179,\"128\":0.03885,\"129\":0.0883,\"130\":0.16247,\"131\":0.11656,\"132\":0.02472,\"133\":0.0883,\"134\":0.02826,\"135\":0.02119,\"136\":0.02826,\"137\":0.03532,\"138\":0.19426,\"139\":0.12362,\"140\":0.09183,\"141\":0.4521,\"142\":6.56599,\"143\":9.20439,\"144\":0.00353,\"145\":0.00353,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 51 52 53 54 55 57 58 59 60 61 62 63 64 65 66 67 70 71 72 73 74 75 76 77 81 83 84 85 89 92 93 95 96 97 100 101 102 113 146\"},F:{\"87\":0.00353,\"93\":0.0777,\"95\":0.00353,\"106\":0.00353,\"114\":0.00353,\"121\":0.00353,\"123\":0.0106,\"124\":0.30728,\"125\":0.19426,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 5...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/MK.js:\n Line 1: module.exports={C:{\"5\":0.01053,\"48\":0.00351,\"51\":0.00351,\"52\":0.04211,\"60\":0.00351,\"78\":0.00702,\"85\":0.00351,\"93\":0.00351,\"111\":0.00702,\"115\":0.20703,\"118\":0.00351,\"121\":0.00702,\"125\":0.00351,\"127\":0.00351,\"128\":0.00351,\"132\":0.02456,\"133\":0.00351,\"134\":0.00351,\"136\":0.00351,\"137\":0.00351,\"139\":0.00351,\"140\":0.01404,\"141\":0.00702,\"142\":0.01053,\"143\":0.01404,\"144\":0.00351,\"145\":0.52986,\"146\":0.86672,\"147\":0.00351,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 49 50 53 54 55 56 57 58 59 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 112 113 114 116 117 119 120 122 123 124 126 129 130 131 135 138 148 149 3.5 3.6\"},D:{\"38\":0.00351,\"49\":0.00702,\"53\":0.00351,\"55\":0.00351,\"56\":0.00702,\"66\":0.00351,\"68\":0.00351,\"69\":0.04562,\"70\":0.01404,\"72\":0.00351,\"73\":0.00702,\"75\":0.00351,\"79\":0.18949,\"83\":0.03158,\"86\":0.00351,\"87\":0.08773,\"89\":0.00351,\"91\":0.00351,\"93\":0.00351,\"94\":0.01404,\"95\":0.01404,\"98\":0.00702,\"99\":0.00351,\"101\":0.00351,\"102\":0.01404,\"103\":0.04211,\"104\":0.03158,\"105\":0.03158,\"106\":0.03509,\"107\":0.03158,\"108\":0.04211,\"109\":1.65274,\"110\":0.0386,\"111\":0.04913,\"112\":1.85977,\"113\":0.00351,\"114\":0.01755,\"116\":0.08422,\"117\":0.03509,\"118\":0.00702,\"119\":0.01053,\"120\":0.05264,\"121\":0.00702,\"122\":0.05965,\"123\":0.01755,\"124\":0.04211,\"125\":0.21054,\"126\":0.57197,\"127\":0.01053,\"128\":0.05965,\"129\":0.00702,\"130\":0.01053,\"131\":0.16492,\"132\":0.0386,\"133\":0.16141,\"134\":0.05264,\"135\":0.04562,\"136\":0.02456,\"137\":0.04211,\"138\":0.1158,\"139\":0.29125,\"140\":0.17194,\"141\":0.20703,\"142\":6.68465,\"143\":11.32003,\"144\":0.00351,\"145\":0.00351,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 43 44 45 46 47 48 50 51 52 54 57 58 59 60 61 62 63 64 65 67 71 74 76 77 78 80 81 84 85 88 90 92 96 97 100 115 146\"},F:{\"36\":0.02456,\"46\":0.03158,\"79\":0.00351,\"90\":0....\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/MG.js:\n Line 1: module.exports={C:{\"5\":0.01849,\"44\":0.00462,\"48\":0.00462,\"52\":0.00925,\"72\":0.00925,\"75\":0.00462,\"76\":0.00462,\"78\":0.01387,\"85\":0.00925,\"87\":0.00462,\"88\":0.00462,\"94\":0.00462,\"99\":0.00462,\"114\":0.00462,\"115\":0.49466,\"125\":0.00925,\"127\":0.01849,\"128\":0.00925,\"129\":0.00462,\"133\":0.00462,\"134\":0.00462,\"135\":0.00462,\"136\":0.03236,\"137\":0.00462,\"138\":0.00462,\"139\":0.00925,\"140\":0.17105,\"141\":0.02774,\"142\":0.01849,\"143\":0.04161,\"144\":0.07859,\"145\":0.97545,\"146\":1.84458,\"147\":0.01849,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 45 46 47 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 73 74 77 79 80 81 82 83 84 86 89 90 91 92 93 95 96 97 98 100 101 102 103 104 105 106 107 108 109 110 111 112 113 116 117 118 119 120 121 122 123 124 126 130 131 132 148 149 3.5 3.6\"},D:{\"42\":0.00462,\"43\":0.00462,\"55\":0.00462,\"56\":0.00462,\"58\":0.00462,\"60\":0.01387,\"61\":0.00462,\"63\":0.00462,\"64\":0.00925,\"65\":0.00462,\"66\":0.00462,\"67\":0.00462,\"68\":0.00462,\"69\":0.01387,\"70\":0.00925,\"71\":0.00462,\"73\":0.01849,\"74\":0.00462,\"75\":0.02312,\"76\":0.00462,\"78\":0.00462,\"79\":0.03236,\"80\":0.03236,\"81\":0.03698,\"83\":0.00925,\"84\":0.00462,\"85\":0.01387,\"86\":0.01387,\"87\":0.02774,\"88\":0.00462,\"89\":0.00462,\"90\":0.00462,\"91\":0.01849,\"93\":0.00462,\"94\":0.00462,\"95\":0.01387,\"96\":0.00462,\"97\":0.00925,\"99\":0.00462,\"100\":0.01387,\"101\":0.01849,\"102\":0.00462,\"103\":0.02774,\"104\":0.00462,\"105\":0.01387,\"106\":0.03236,\"107\":0.01387,\"108\":0.00925,\"109\":1.77986,\"110\":0.00925,\"111\":0.03236,\"112\":0.00925,\"113\":0.00462,\"114\":0.01849,\"115\":0.00925,\"116\":0.08784,\"117\":0.00925,\"118\":0.00925,\"119\":0.01849,\"120\":0.02774,\"121\":0.01387,\"122\":0.07397,\"123\":0.04161,\"124\":0.01849,\"125\":0.10171,\"126\":0.10633,\"127\":0.02312,\"128\":0.06472,\"129\":0.06472,\"130\":0.03698,\"131\":0.08784,\"132\":0.03698,\"133\":0.06472,\"134\":0.0601,\"135\":0.11558,\"136\":0.0601,\"137\":0.12482,\"138\":0.39758,\"139\":0.2404,\"140\":0.3421,\"141\":0.56401,\"142\":6.4722,\"143\":15.91699,_:\"4 5 6 7 8 9 10 11...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/MD.js:\n Line 1: module.exports={C:{\"5\":0.03354,\"52\":0.03354,\"57\":0.00479,\"78\":0.01437,\"115\":0.20601,\"123\":0.00479,\"125\":0.00479,\"127\":0.00479,\"128\":0.01916,\"133\":0.01437,\"137\":0.00479,\"139\":0.00479,\"140\":0.15331,\"141\":0.00479,\"142\":0.00479,\"143\":0.00479,\"144\":0.01437,\"145\":0.49826,\"146\":0.68511,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 124 126 129 130 131 132 134 135 136 138 147 148 149 3.5 3.6\"},D:{\"48\":0.00479,\"49\":0.00479,\"65\":0.00479,\"69\":0.03354,\"70\":0.01437,\"74\":0.00479,\"79\":0.08624,\"85\":0.01437,\"86\":0.00479,\"87\":0.00958,\"90\":0.00958,\"92\":0.00479,\"97\":0.00479,\"98\":0.00958,\"102\":0.14852,\"103\":0.16289,\"104\":0.14852,\"105\":0.14852,\"106\":0.23476,\"107\":0.15331,\"108\":0.15331,\"109\":1.89724,\"110\":0.14852,\"111\":0.18685,\"112\":6.94216,\"114\":0.00479,\"116\":0.32579,\"117\":0.15331,\"118\":0.01916,\"119\":0.02396,\"120\":0.17248,\"121\":0.00958,\"122\":0.07666,\"123\":0.00958,\"124\":0.1581,\"125\":0.27309,\"126\":2.2374,\"127\":0.00479,\"128\":0.01916,\"129\":0.08624,\"130\":0.01437,\"131\":0.34495,\"132\":0.05749,\"133\":0.31621,\"134\":0.04312,\"135\":0.02875,\"136\":0.02875,\"137\":0.08145,\"138\":0.56055,\"139\":0.12457,\"140\":0.09582,\"141\":0.3737,\"142\":7.33981,\"143\":8.53277,\"144\":0.00479,\"145\":0.00958,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 66 67 68 71 72 73 75 76 77 78 80 81 83 84 88 89 91 93 94 95 96 99 100 101 113 115 146\"},F:{\"79\":0.08624,\"82\":0.00479,\"85\":0.03833,\"86\":0.00479,\"87\":0.00479,\"91\":0.00958,\"92\":0.00479,\"93\":0.13415,\"94\":0.00479,\"95\":0.14373,\"114\":0.00479,\"120\":0.00479,\"122\":0.00479,\"123\":0.01916,\"124\":0.96778,\"125\":0.47431,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/MC.js:\n Line 1: module.exports={C:{\"5\":0.00616,\"115\":4.07308,\"128\":0.03697,\"133\":0.01849,\"134\":0.01232,\"135\":0.06162,\"136\":0.03081,\"138\":0.03081,\"139\":0.01232,\"140\":0.41902,\"144\":0.00616,\"145\":0.875,\"146\":1.34332,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 129 130 131 132 137 141 142 143 147 148 149 3.5 3.6\"},D:{\"53\":0.00616,\"87\":0.01232,\"88\":0.00616,\"90\":0.01849,\"97\":0.00616,\"98\":0.08011,\"99\":0.01849,\"103\":1.44807,\"107\":0.01232,\"109\":0.28345,\"112\":0.03081,\"116\":0.10475,\"119\":0.00616,\"120\":0.02465,\"122\":0.0493,\"125\":0.05546,\"126\":0.02465,\"127\":0.00616,\"128\":0.04313,\"129\":0.00616,\"130\":0.01849,\"131\":0.32042,\"132\":0.1787,\"133\":0.14173,\"134\":0.19718,\"135\":0.25264,\"136\":0.22183,\"137\":0.17254,\"138\":0.43134,\"139\":0.03081,\"140\":0.09243,\"141\":0.3574,\"142\":6.61799,\"143\":9.1136,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 83 84 85 86 89 91 92 93 94 95 96 100 101 102 104 105 106 108 110 111 113 114 115 117 118 121 123 124 144 145 146\"},F:{\"95\":0.03081,\"108\":0.00616,\"114\":0.00616,\"118\":0.02465,\"120\":0.00616,\"123\":0.08011,\"124\":17.90677,\"125\":1.60212,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 96 97 98 99 100 101 102 103 104 105 106 107 109 110 111 112 113 115 116 117 119 121 122 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"86\":0.01849,\"92\":0.01849,\"98\":0.01232,\"12...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/LT.js:\n Line 1: module.exports={C:{\"5\":0.00523,\"52\":0.00523,\"77\":0.02092,\"78\":0.00523,\"106\":0.04183,\"115\":0.26145,\"120\":0.01046,\"121\":0.00523,\"123\":0.00523,\"125\":0.00523,\"128\":0.01046,\"129\":0.00523,\"132\":0.01569,\"133\":0.00523,\"134\":0.00523,\"135\":0.00523,\"136\":0.01046,\"137\":0.05229,\"138\":0.00523,\"139\":0.02092,\"140\":0.11504,\"141\":0.01046,\"142\":0.01569,\"143\":0.02615,\"144\":0.06275,\"145\":1.13469,\"146\":1.46412,\"147\":0.00523,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 107 108 109 110 111 112 113 114 116 117 118 119 122 124 126 127 130 131 148 149 3.5 3.6\"},D:{\"39\":0.10981,\"40\":0.10981,\"41\":0.10981,\"42\":0.10981,\"43\":0.10981,\"44\":0.10458,\"45\":0.11504,\"46\":0.10458,\"47\":0.10981,\"48\":0.10981,\"49\":0.10981,\"50\":0.10981,\"51\":0.10981,\"52\":0.11504,\"53\":0.10981,\"54\":0.10981,\"55\":0.10981,\"56\":0.11504,\"57\":0.10981,\"58\":0.11504,\"59\":0.10981,\"60\":0.10981,\"69\":0.00523,\"79\":0.05229,\"81\":0.00523,\"85\":0.05752,\"87\":0.02092,\"88\":0.14641,\"90\":0.01046,\"91\":0.00523,\"98\":0.00523,\"102\":0.00523,\"103\":0.01046,\"104\":0.00523,\"105\":0.00523,\"106\":0.01569,\"108\":0.02615,\"109\":0.69546,\"111\":0.01046,\"112\":0.01046,\"113\":0.05229,\"114\":0.17779,\"115\":0.21962,\"116\":0.24576,\"117\":0.00523,\"118\":0.01046,\"119\":0.02615,\"120\":0.13073,\"121\":9.05663,\"122\":0.33989,\"123\":0.01046,\"124\":0.04183,\"125\":0.22485,\"126\":0.03137,\"127\":0.01046,\"128\":0.02615,\"129\":0.10981,\"130\":0.02092,\"131\":0.19347,\"132\":0.03137,\"133\":0.05229,\"134\":0.0366,\"135\":0.0366,\"136\":0.06275,\"137\":0.06275,\"138\":0.25099,\"139\":1.95565,\"140\":0.15164,\"141\":0.47061,\"142\":6.78724,\"143\":12.03193,\"144\":0.00523,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 63 64 65 66 67 68 70 71 72 73 74 75 76 77 78 80 83 84 86 89 92 93 94 95 96 97 99 100 101 107 110 145 146\"},F:{\"93\":...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/LK.js:\n Line 1: module.exports={C:{\"5\":0.0071,\"72\":0.0071,\"115\":0.09946,\"140\":0.02131,\"142\":0.02842,\"143\":0.01421,\"144\":0.01421,\"145\":0.46886,\"146\":0.61805,\"147\":0.0071,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 141 148 149 3.5 3.6\"},D:{\"63\":0.0071,\"69\":0.0071,\"70\":0.0071,\"71\":0.0071,\"74\":0.01421,\"76\":0.0071,\"78\":0.0071,\"79\":0.02131,\"80\":0.0071,\"81\":0.0071,\"86\":0.0071,\"87\":0.0071,\"91\":0.0071,\"92\":0.0071,\"93\":0.0071,\"94\":0.0071,\"97\":0.0071,\"99\":0.0071,\"101\":0.0071,\"103\":0.05683,\"104\":0.01421,\"105\":0.01421,\"106\":0.01421,\"107\":0.01421,\"108\":0.01421,\"109\":0.7104,\"110\":0.01421,\"111\":0.02131,\"112\":0.63936,\"114\":0.0071,\"116\":0.03552,\"117\":0.01421,\"119\":0.0071,\"120\":0.02842,\"121\":0.0071,\"122\":0.02842,\"123\":0.0071,\"124\":0.02131,\"125\":0.12077,\"126\":0.1776,\"127\":0.02842,\"128\":0.02842,\"129\":0.02131,\"130\":0.01421,\"131\":0.08525,\"132\":0.02842,\"133\":0.03552,\"134\":0.02131,\"135\":0.04973,\"136\":0.04262,\"137\":0.04262,\"138\":0.12787,\"139\":0.09946,\"140\":0.14918,\"141\":0.22022,\"142\":6.07392,\"143\":9.90298,\"144\":0.01421,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 64 65 66 67 68 72 73 75 77 83 84 85 88 89 90 95 96 98 100 102 113 115 118 145 146\"},F:{\"92\":0.02131,\"93\":0.05683,\"95\":0.07814,\"123\":0.0071,\"124\":0.51149,\"125\":0.19181,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 94 96 97 98 99 100 101 102 103 104...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/LR.js:\n Line 1: module.exports={C:{\"5\":0.01002,\"57\":0.00334,\"58\":0.01002,\"72\":0.00334,\"85\":0.00334,\"94\":0.01002,\"112\":0.00334,\"115\":0.00668,\"121\":0.00668,\"127\":0.01002,\"129\":0.00334,\"136\":0.00334,\"138\":0.00668,\"139\":0.01002,\"140\":0.02339,\"142\":0.00668,\"143\":0.03341,\"144\":0.01002,\"145\":0.5947,\"146\":0.37753,\"147\":0.01336,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 59 60 61 62 63 64 65 66 67 68 69 70 71 73 74 75 76 77 78 79 80 81 82 83 84 86 87 88 89 90 91 92 93 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 113 114 116 117 118 119 120 122 123 124 125 126 128 130 131 132 133 134 135 137 141 148 149 3.5 3.6\"},D:{\"32\":0.00334,\"47\":0.00334,\"48\":0.00334,\"54\":0.00334,\"57\":0.00334,\"58\":0.00334,\"59\":0.01671,\"64\":0.00668,\"67\":0.00334,\"68\":0.00334,\"69\":0.01671,\"70\":0.00668,\"71\":0.00334,\"75\":0.00334,\"76\":0.00334,\"77\":0.00334,\"78\":0.01002,\"79\":0.00334,\"80\":0.01002,\"81\":0.00334,\"92\":0.00668,\"93\":0.01002,\"94\":0.01002,\"95\":0.00334,\"96\":0.00334,\"98\":0.01671,\"103\":0.0735,\"104\":0.00668,\"105\":0.02005,\"106\":0.01002,\"107\":0.37753,\"108\":0.00668,\"109\":0.12362,\"110\":0.01002,\"111\":0.05012,\"112\":0.00668,\"113\":0.00334,\"114\":0.00334,\"116\":0.03341,\"117\":0.00668,\"119\":0.02005,\"120\":0.06348,\"122\":0.01002,\"124\":0.03007,\"125\":0.04009,\"126\":0.10691,\"127\":0.01002,\"128\":0.03341,\"129\":0.04677,\"130\":0.01002,\"131\":0.07684,\"132\":0.08018,\"133\":0.02339,\"134\":0.0568,\"135\":0.03675,\"136\":0.06682,\"137\":0.04677,\"138\":0.23387,\"139\":0.2606,\"140\":0.07016,\"141\":0.24723,\"142\":3.30425,\"143\":3.11381,\"144\":0.01002,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 33 34 35 36 37 38 39 40 41 42 43 44 45 46 49 50 51 52 53 55 56 60 61 62 63 65 66 72 73 74 83 84 85 86 87 88 89 90 91 97 99 100 101 102 115 118 121 123 145 146\"},F:{\"37\":0.00334,\"54\":0.00334,\"63\":0.00334,\"67\":0.00334,\"79\":0.00334,\"90\":0.01336,\"91\":0.00334,\"92\":0.03675,\"93\":0.22385,\"95\":0.01002,\"108\":0.01336,\"113\":0.00334,...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/LI.js:\n Line 1: module.exports={C:{\"3\":0.12785,\"43\":0.02019,\"115\":0.31626,\"127\":0.00673,\"133\":0.04037,\"135\":0.06056,\"136\":0.05383,\"137\":0.06729,\"138\":0.10766,\"139\":0.02692,\"140\":0.08748,\"142\":0.01346,\"144\":1.19103,\"145\":1.36599,\"146\":4.36712,_:\"2 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 128 129 130 131 132 134 141 143 147 148 149 3.5 3.6\"},D:{\"48\":1.39963,\"92\":0.00673,\"108\":0.02692,\"109\":0.16823,\"112\":0.04037,\"114\":0.01346,\"116\":0.10094,\"117\":0.01346,\"119\":0.00673,\"120\":0.01346,\"122\":0.12785,\"123\":0.0471,\"124\":0.64598,\"125\":0.06056,\"126\":0.01346,\"127\":0.02019,\"128\":0.00673,\"130\":0.01346,\"131\":0.64598,\"132\":0.30953,\"133\":0.10766,\"134\":0.26243,\"135\":0.21533,\"136\":0.14804,\"137\":0.10766,\"138\":0.86804,\"139\":0.02019,\"140\":0.3701,\"141\":0.26916,\"142\":6.97797,\"143\":16.23035,\"145\":0.02019,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 83 84 85 86 87 88 89 90 91 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 110 111 113 115 118 121 129 144 146\"},F:{\"93\":0.04037,\"114\":0.14804,\"115\":0.04037,\"116\":0.04037,\"119\":0.06056,\"123\":0.00673,\"124\":1.58132,\"125\":0.4172,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 117 118 120 121 122 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\",\"9.5-9.6\":0.02019},B:{\"18\":0.00673,\"92\":0.02019,\"98...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/LB.js:\n Line 1: module.exports={C:{\"5\":0.09296,\"52\":0.0062,\"66\":0.0062,\"115\":0.11155,\"128\":0.0062,\"140\":0.01239,\"141\":0.0062,\"143\":0.01239,\"144\":0.0062,\"145\":0.30985,\"146\":0.46478,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 129 130 131 132 133 134 135 136 137 138 139 142 147 148 149 3.5 3.6\"},D:{\"38\":0.0062,\"60\":0.0062,\"64\":0.0062,\"65\":0.0062,\"66\":0.0062,\"67\":0.0062,\"68\":0.0062,\"69\":0.09915,\"70\":0.0062,\"71\":0.0062,\"75\":0.0062,\"77\":0.0062,\"79\":0.01859,\"80\":0.0062,\"83\":0.0062,\"86\":0.01239,\"87\":0.03099,\"89\":0.0062,\"94\":0.01239,\"95\":0.0062,\"98\":0.01859,\"100\":0.0062,\"101\":0.0062,\"103\":0.35323,\"104\":0.34703,\"105\":0.34084,\"106\":0.34703,\"107\":0.34703,\"108\":0.35323,\"109\":0.92955,\"110\":0.34703,\"111\":0.43379,\"112\":24.32942,\"114\":0.0062,\"116\":0.74364,\"117\":0.34084,\"119\":0.01859,\"120\":0.37182,\"121\":0.0062,\"122\":0.2107,\"123\":0.04338,\"124\":0.35323,\"125\":0.24788,\"126\":5.74462,\"127\":0.0062,\"128\":0.01859,\"129\":0.02479,\"130\":0.01239,\"131\":0.73125,\"132\":0.10535,\"133\":0.70026,\"134\":0.04338,\"135\":0.04338,\"136\":0.03718,\"137\":0.02479,\"138\":0.18591,\"139\":0.13633,\"140\":0.06817,\"141\":0.22309,\"142\":4.70352,\"143\":6.44488,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 61 62 63 72 73 74 76 78 81 84 85 88 90 91 92 93 96 97 99 102 113 115 118 144 145 146\"},F:{\"46\":0.0062,\"53\":0.0062,\"54\":0.0062,\"55\":0.0062,\"56\":0.0062,\"93\":0.03718,\"94\":0.0062,\"95\":0.01239,\"122\":0.01859,\"124\":0.30365,\"125\":0.13633,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 47 48 49 50 51 52 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/KP.js:\n Line 1: module.exports={C:{\"115\":14.28913,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 3.5 3.6\"},D:{\"141\":5.71674,\"143\":8.57239,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 142 144 145 146\"},F:{\"56\":2.85565,\"124\":17.14478,\"125\":2.85565,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"92\":2.85565,_:\"12 13 14 15 16 17 18 79 80 81 83 84 85 86 87 88 89 90 91 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143\"},E:{_:\"0 4 5 6 7 8 9 10 11 12 13 14 15 3.1 3.2 5.1 6.1 7.1 9.1 10.1 11.1 12.1 13.1 14.1 15.1 15.2-15.3 15.4 15.5 15.6 16.0 16.1 16.2 16.3 16.4 16.5 16.6 17.0 17.1 17.2 17.3 17.4 17.5 17.6 18.0 18.1 18.2 18.3 18.4 18.5-18.6 26.0 26.1 26.2 26.3\"},G:{\"8\":0,...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/KR.js:\n Line 1: module.exports={C:{\"52\":0.00528,\"92\":0.00528,\"115\":0.46482,\"120\":0.01056,\"132\":0.04226,\"135\":0.00528,\"136\":0.00528,\"137\":0.01056,\"140\":0.01056,\"141\":0.01056,\"142\":0.02113,\"144\":0.00528,\"145\":0.27995,\"146\":0.40671,\"147\":0.01056,\"148\":0.01056,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 121 122 123 124 125 126 127 128 129 130 131 133 134 138 139 143 149 3.5 3.6\"},D:{\"39\":0.02641,\"40\":0.02641,\"41\":0.02641,\"42\":0.04226,\"43\":0.02641,\"44\":0.02641,\"45\":0.02641,\"46\":0.02641,\"47\":0.02641,\"48\":0.02641,\"49\":0.03169,\"50\":0.02641,\"51\":0.02641,\"52\":0.02641,\"53\":0.02641,\"54\":0.02641,\"55\":0.02641,\"56\":0.02641,\"57\":0.02641,\"58\":0.02641,\"59\":0.02641,\"60\":0.02641,\"61\":0.01056,\"65\":0.00528,\"79\":0.01056,\"80\":0.01056,\"83\":0.00528,\"87\":0.01056,\"95\":0.00528,\"96\":0.02113,\"98\":0.00528,\"103\":0.00528,\"105\":0.01585,\"106\":0.00528,\"107\":0.00528,\"108\":1.02471,\"109\":0.33277,\"111\":4.66929,\"112\":0.00528,\"114\":0.00528,\"116\":0.02113,\"118\":0.00528,\"119\":0.00528,\"120\":0.06867,\"121\":0.04754,\"122\":0.03169,\"123\":0.04226,\"124\":0.01585,\"125\":0.01056,\"126\":0.03697,\"127\":0.01056,\"128\":0.04226,\"129\":0.01056,\"130\":0.02641,\"131\":0.07395,\"132\":0.03697,\"133\":0.0581,\"134\":0.07923,\"135\":0.13733,\"136\":0.04754,\"137\":0.04226,\"138\":0.13733,\"139\":0.12149,\"140\":0.10564,\"141\":0.21128,\"142\":9.06919,\"143\":18.04331,\"144\":0.03169,\"145\":0.00528,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 62 63 64 66 67 68 69 70 71 72 73 74 75 76 77 78 81 84 85 86 88 89 90 91 92 93 94 97 99 100 101 102 104 110 113 115 117 146\"},F:{\"93\":0.03697,\"95\":0.00528,\"114\":0.00528,\"118\":0.00528,\"124\":0.30636,\"125\":0.08451,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 3...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/KN.js:\n Line 1: module.exports={C:{\"5\":0.12989,\"115\":0.13917,\"131\":0.00928,\"140\":0.04639,\"143\":0.00928,\"144\":0.0232,\"145\":0.23195,\"146\":0.4871,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 132 133 134 135 136 137 138 139 141 142 147 148 149 3.5 3.6\"},D:{\"65\":0.00464,\"69\":0.09742,\"79\":0.01856,\"87\":0.06031,\"97\":0.18092,\"103\":0.52885,\"106\":0.00464,\"107\":0.00464,\"109\":0.10206,\"111\":0.13453,\"112\":0.09742,\"114\":0.02783,\"116\":0.04639,\"119\":0.00464,\"122\":0.01392,\"124\":0.00464,\"125\":0.63554,\"126\":0.04175,\"128\":0.19484,\"131\":0.03711,\"132\":0.12989,\"133\":0.01392,\"134\":0.00464,\"135\":0.00464,\"136\":0.00928,\"137\":0.03247,\"138\":0.08814,\"139\":0.16237,\"140\":0.09742,\"141\":0.75616,\"142\":6.98633,\"143\":15.37829,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 66 67 68 70 71 72 73 74 75 76 77 78 80 81 83 84 85 86 88 89 90 91 92 93 94 95 96 98 99 100 101 102 104 105 108 110 113 115 117 118 120 121 123 127 129 130 144 145 146\"},F:{\"93\":0.01392,\"124\":0.27834,\"125\":0.01392,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"18\":0.00464,\"109\":0.00928,\"129\":0.00928,\"134\":0.00464,\"138\":0.05103,\"139\":0.02783,\"141\":0.49173,\"142\":1.42881,\"143\":4.22613,_:\"12 13 14 15 16 17 79 80 81 83 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/lucide-react/dist/esm/icons/cctv.js:\n Line 21: d: \"M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/JO.js:\n Line 1: module.exports={C:{\"5\":0.03028,\"115\":0.03634,\"121\":0.00303,\"128\":0.00303,\"136\":0.00303,\"140\":0.01211,\"145\":0.15746,\"146\":0.2483,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 122 123 124 125 126 127 129 130 131 132 133 134 135 137 138 139 141 142 143 144 147 148 149 3.5 3.6\"},D:{\"66\":0.00303,\"68\":0.00303,\"69\":0.03331,\"73\":0.00303,\"79\":0.00908,\"83\":0.00303,\"87\":0.00908,\"95\":0.00303,\"96\":0.00606,\"98\":0.03028,\"100\":0.00606,\"103\":0.17562,\"104\":0.17865,\"105\":0.1726,\"106\":0.17562,\"107\":0.1726,\"108\":0.18471,\"109\":0.70552,\"110\":0.1726,\"111\":0.2059,\"112\":7.33382,\"113\":0.00303,\"114\":0.00606,\"115\":0.00303,\"116\":0.35125,\"117\":0.21499,\"119\":0.01211,\"120\":0.20893,\"121\":0.00606,\"122\":0.11204,\"123\":0.00303,\"124\":0.17865,\"125\":0.0757,\"126\":3.11581,\"127\":0.00606,\"128\":0.03936,\"129\":0.00606,\"130\":0.00303,\"131\":0.36336,\"132\":0.04542,\"133\":0.35125,\"134\":0.01211,\"135\":0.01514,\"136\":0.01211,\"137\":0.02725,\"138\":0.0969,\"139\":0.03936,\"140\":0.03028,\"141\":0.09387,\"142\":3.10067,\"143\":4.71762,\"144\":0.00303,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 67 70 71 72 74 75 76 77 78 80 81 84 85 86 88 89 90 91 92 93 94 97 99 101 102 118 145 146\"},F:{\"56\":0.00303,\"93\":0.01211,\"113\":0.00303,\"120\":0.01514,\"121\":0.00303,\"122\":0.00606,\"123\":0.00303,\"124\":0.06056,\"125\":0.02725,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 1...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/IQ.js:\n Line 1: module.exports={C:{\"5\":0.02108,\"52\":0.00351,\"101\":0.01054,\"115\":0.03162,\"122\":0.01054,\"123\":0.03864,\"140\":0.00351,\"145\":0.0281,\"146\":0.03513,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 141 142 143 144 147 148 149 3.5 3.6\"},D:{\"38\":0.00351,\"43\":0.00351,\"56\":0.00351,\"63\":0.00703,\"65\":0.00351,\"66\":0.00351,\"68\":0.00351,\"69\":0.0281,\"70\":0.00351,\"72\":0.00351,\"73\":0.00703,\"74\":0.00703,\"75\":0.00703,\"79\":0.03864,\"80\":0.00351,\"81\":0.00703,\"83\":0.04216,\"86\":0.00351,\"87\":0.0527,\"88\":0.00351,\"89\":0.00351,\"90\":0.00351,\"91\":0.01757,\"93\":0.00703,\"94\":0.00351,\"95\":0.01054,\"96\":0.00351,\"97\":0.00351,\"98\":0.04918,\"99\":0.00351,\"100\":0.00351,\"101\":0.00703,\"102\":0.02108,\"103\":0.15809,\"104\":0.12647,\"105\":0.12647,\"106\":0.12647,\"107\":0.12296,\"108\":0.13349,\"109\":0.46723,\"110\":0.13701,\"111\":0.14755,\"112\":6.26017,\"113\":0.00703,\"114\":0.02459,\"116\":0.25294,\"117\":0.12296,\"119\":0.01757,\"120\":0.14755,\"121\":0.01757,\"122\":0.07377,\"123\":0.00703,\"124\":0.12647,\"125\":0.04567,\"126\":2.39235,\"127\":0.01054,\"128\":0.01054,\"129\":0.00703,\"130\":0.00351,\"131\":0.26348,\"132\":0.0281,\"133\":0.25294,\"134\":0.01054,\"135\":0.00703,\"136\":0.00703,\"137\":0.0281,\"138\":0.0527,\"139\":0.54452,\"140\":0.01757,\"141\":0.0281,\"142\":0.90284,\"143\":2.48018,\"144\":0.00703,\"145\":0.00351,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 39 40 41 42 44 45 46 47 48 49 50 51 52 53 54 55 57 58 59 60 61 62 64 67 71 76 77 78 84 85 92 115 118 146\"},F:{\"92\":0.00351,\"93\":0.07729,\"94\":0.00351,\"95\":0.00351,\"124\":0.04216,\"125\":0.02108,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/features/http3.js:\n Line 1: module.exports={A:{A:{\"2\":\"K D E F A B 1C\"},B:{\"1\":\"0 1 2 3 4 5 6 7 8 W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I\",\"2\":\"C L M G N O P\",\"322\":\"Q H R S T\",\"578\":\"U V\"},C:{\"1\":\"0 1 2 3 4 5 6 7 8 X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I aC PC bC 3C 4C\",\"2\":\"9 2C WC J cB K D E F A B C L M G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B XC 7B YC 8B 9B AC BC CC DC EC FC GC HC 5C 6C\",\"194\":\"IC JC KC LC MC NC OC Q H R ZC S T U V W\"},D:{\"1\":\"0 1 2 3 4 5 6 7 8 W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z JB KB LB MB NB OB PB QB RB SB TB UB VB WB XB YB ZB aB bB I aC PC bC\",\"2\":\"9 J cB K D E F A B C L M G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B XC 7B YC 8B 9B AC BC CC DC EC FC GC HC IC JC KC LC MC NC OC\",\"322\":\"Q H R S T\",\"578\":\"U V\"},E:{\"1\":\"VC qC rC sC tC ID uC vC wC xC yC JD\",\"2\":\"J cB K D E F A B C L 7C cC 8C 9C AD BD dC QC RC CD\",\"2049\":\"jC kC GD UC lC mC nC oC pC HD\",\"2113\":\"TC gC hC iC\",\"3140\":\"M G DD ED eC fC SC FD\"},F:{\"1\":\"0 1 2 3 4 5 6 7 8 KC LC MC NC OC Q H R ZC S T U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z\",\"2\":\"9 F B C G N O P dB AB BB CB DB EB FB GB HB IB eB fB gB hB iB jB kB lB mB nB oB pB qB rB sB tB uB vB wB xB yB zB 0B 1B 2B 3B 4B 5B 6B 7B 8B 9B AC BC CC DC EC FC GC HC IC KD LD MD ND QC zC OD RC\",\"578\":\"JC\"},G:{\"1\":\"VC qC rC sC tC mD uC vC wC xC yC\",\"2\":\"E cC PD 0C QD RD SD TD UD VD WD XD YD ZD aD bD cD dD eD fD\",\"2049\":\"jC kC kD UC lC mC nC oC pC lD\",\"2113\":\"TC gC hC iC\",\"2116\":\"gD hD iD eC fC SC jD\"},H:{\"2\":\"nD\"},I:{\"1\":\"I\",\"2\":\"WC J oD pD qD rD 0C sD tD\"},J:{\"2\":\"D A\"},K:{\"1\":\"H\",\"2\":\"A B C QC zC RC\"},L:{\"1\":\"I\"},M:{\"1\":\"PC\"},N:{\"2\":\"A B\"},O:{\"1\":\"SC\"},P:{\"1\":\"wD\",\"2\":\"9 J AB BB CB DB EB FB GB uD vD xD yD dC zD 0D 1...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/HK.js:\n Line 1: module.exports={C:{\"5\":0.02516,\"52\":0.01258,\"78\":0.02516,\"115\":0.06709,\"126\":0.00419,\"128\":0.03774,\"131\":0.00419,\"133\":0.00419,\"134\":0.00419,\"135\":0.00419,\"136\":0.00839,\"138\":0.00419,\"139\":0.00419,\"140\":0.02935,\"141\":0.00419,\"142\":0.00419,\"143\":0.01258,\"144\":0.00839,\"145\":0.39834,\"146\":0.68765,\"147\":0.00419,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 127 129 130 132 137 148 149 3.5 3.6\"},D:{\"39\":0.02097,\"40\":0.02097,\"41\":0.02097,\"42\":0.02097,\"43\":0.02097,\"44\":0.02097,\"45\":0.02097,\"46\":0.02516,\"47\":0.02097,\"48\":0.02516,\"49\":0.02516,\"50\":0.02516,\"51\":0.02516,\"52\":0.02516,\"53\":0.02516,\"54\":0.02097,\"55\":0.02097,\"56\":0.02097,\"57\":0.02516,\"58\":0.02516,\"59\":0.02516,\"60\":0.02516,\"69\":0.02516,\"75\":0.00419,\"78\":0.00839,\"79\":0.03774,\"80\":0.00419,\"81\":0.00419,\"83\":0.00839,\"85\":0.00419,\"86\":0.03774,\"87\":0.02516,\"90\":0.00419,\"91\":0.02935,\"92\":0.00419,\"94\":0.00419,\"96\":0.00419,\"97\":0.01677,\"98\":0.02935,\"99\":0.00839,\"100\":0.00419,\"101\":0.05451,\"102\":0.00419,\"103\":0.01677,\"104\":0.01258,\"105\":0.02935,\"106\":0.00839,\"107\":0.04193,\"108\":0.01677,\"109\":0.63734,\"110\":0.01258,\"111\":0.03354,\"112\":0.01677,\"113\":0.01677,\"114\":0.06709,\"115\":0.05032,\"116\":0.04193,\"117\":0.01258,\"118\":0.02097,\"119\":0.04612,\"120\":0.12998,\"121\":0.09225,\"122\":0.07128,\"123\":0.07547,\"124\":0.15933,\"125\":0.16353,\"126\":0.08386,\"127\":0.0587,\"128\":0.20126,\"129\":0.13418,\"130\":0.18869,\"131\":0.239,\"132\":0.10902,\"133\":0.1216,\"134\":0.08805,\"135\":0.09644,\"136\":0.0629,\"137\":0.16353,\"138\":0.26416,\"139\":0.20965,\"140\":0.21804,\"141\":0.31867,\"142\":6.67945,\"143\":10.74666,\"144\":0.05451,\"145\":0.09225,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 6...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/GW.js:\n Line 1: module.exports={C:{\"5\":0.00537,\"49\":0.00537,\"125\":0.00269,\"136\":0.00806,\"145\":0.25239,\"146\":0.28998,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 126 127 128 129 130 131 132 133 134 135 137 138 139 140 141 142 143 144 147 148 149 3.5 3.6\"},D:{\"58\":0.00537,\"59\":0.00537,\"69\":0.00537,\"73\":0.00806,\"77\":0.04565,\"79\":0.00537,\"81\":0.01074,\"86\":0.00537,\"88\":0.00537,\"98\":0.00806,\"100\":0.00269,\"103\":0.07787,\"104\":0.00806,\"105\":0.00537,\"106\":0.00269,\"107\":0.00806,\"108\":0.00537,\"109\":0.08324,\"110\":0.03491,\"111\":0.02148,\"112\":0.00269,\"114\":0.05102,\"116\":0.00806,\"117\":0.01343,\"119\":0.0188,\"120\":0.01611,\"121\":0.00537,\"122\":0.01343,\"123\":0.00537,\"124\":0.01343,\"125\":0.15036,\"126\":0.08592,\"127\":0.0188,\"128\":0.04028,\"129\":0.00537,\"130\":0.03222,\"131\":0.03222,\"132\":0.01343,\"133\":0.01343,\"135\":0.01611,\"136\":0.01074,\"137\":0.0188,\"138\":0.89948,\"139\":0.13694,\"140\":0.06713,\"141\":0.30878,\"142\":2.16948,\"143\":2.20439,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 67 68 70 71 72 74 75 76 78 80 83 84 85 87 89 90 91 92 93 94 95 96 97 99 101 102 113 115 118 134 144 145 146\"},F:{\"93\":0.14768,\"120\":0.00269,\"124\":0.02685,\"125\":0.02685,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 121 122 123 9.5-9.6 10.0-10.1 10.5 10.6 11.1 11.5 11.6 12.1\"},B:{\"14\":0.005...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/recharts/umd/Recharts.js:\n Line 2: !function(e,t){\"object\"==typeof exports&&\"object\"==typeof module?module.exports=t(require(\"react\"),require(\"react-dom\"),require(\"react-is\")):\"function\"==typeof define&&define.amd?define([\"react\",\"react-dom\",\"react-is\"],t):\"object\"==typeof exports?exports.Recharts=t(require(\"react\"),require(\"react-dom\"),require(\"react-is\")):e.Recharts=t(e.React,e.ReactDOM,e.ReactIs)}(this,((e,t,r)=>(()=>{var n={8(e,t,r){\"use strict\";Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const n=r(6773);t.debounce=function(e,t=0,r={}){\"object\"!=typeof r&&(r={});const{leading:i=!1,trailing:a=!0,maxWait:o}=r,l=Array(2);let c;i&&(l[0]=\"leading\"),a&&(l[1]=\"trailing\");let u=null;const s=n.debounce((function(...t){c=e.apply(this,t),u=null}),t,{edges:l}),f=function(...t){return null!=o&&(null===u&&(u=Date.now()),Date.now()-u>=o)?(c=e.apply(this,t),u=Date.now(),s.cancel(),s.schedule(),c):(s.apply(this,t),c)};return f.cancel=s.cancel,f.flush=()=>(s.flush(),c),f}},25(e,t,r){e.exports=r(1334).last},58(e,t,r){\"use strict\";Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"});const n=r(9181);t.isArrayLike=function(e){return null!=e&&\"function\"!=typeof e&&n.isLength(e.length)}},184(e,t,r){e.exports=r(4259).sortBy},228(e){\"use strict\";var t=Object.prototype.hasOwnProperty,r=\"~\";function n(){}function i(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,n,a,o){if(\"function\"!=typeof n)throw new TypeError(\"The listener must be a function\");var l=new i(n,a||e,o),c=r?r+t:t;return e._events[c]?e._events[c].fn?e._events[c]=[e._events[c],l]:e._events[c].push(l):(e._events[c]=l,e._eventsCount++),e}function o(e,t){0==--e._eventsCount?e._events=new n:delete e._events[t]}function l(){this._events=new n,this._eventsCount=0}Object.create&&(n.prototype=Object.create(null),(new n).__proto__||(r=!1)),l.prototype.eventNames=function(){var e,n,i=[];if(0===this._eventsCount)return i;for(n in e=this._events)t.call(e,n)&&i.push(r?n.slice(1):n);return Object.getOwnPropertySymbols?i.concat(Object.g...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/GQ.js:\n Line 1: module.exports={C:{\"5\":0.01134,\"46\":0.00567,\"56\":0.00425,\"72\":0.00142,\"103\":0.00142,\"111\":0.00425,\"115\":0.01985,\"118\":0.00425,\"128\":0.00142,\"139\":0.00851,\"140\":0.00142,\"142\":0.00709,\"143\":0.00284,\"144\":0.00425,\"145\":1.95826,\"146\":0.20986,\"147\":0.00284,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 47 48 49 50 51 52 53 54 55 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 104 105 106 107 108 109 110 112 113 114 116 117 119 120 121 122 123 124 125 126 127 129 130 131 132 133 134 135 136 137 138 141 148 149 3.5 3.6\"},D:{\"11\":0.00142,\"32\":0.00142,\"39\":0.00142,\"47\":0.00567,\"57\":0.00284,\"58\":0.00284,\"67\":0.00142,\"68\":0.00142,\"69\":0.00993,\"70\":0.00709,\"71\":0.00142,\"73\":0.00425,\"75\":0.00284,\"79\":0.00567,\"83\":0.00142,\"84\":0.00142,\"86\":0.00284,\"88\":0.00142,\"89\":0.00284,\"94\":0.02978,\"97\":0.00851,\"99\":0.00142,\"100\":0.00142,\"103\":0.00567,\"104\":0.01134,\"105\":0.00425,\"106\":0.00284,\"107\":0.00851,\"108\":0.00425,\"109\":0.60832,\"110\":0.00567,\"111\":0.01276,\"112\":0.00709,\"114\":0.06948,\"116\":0.04254,\"117\":0.00567,\"118\":0.00142,\"119\":0.00851,\"120\":0.01843,\"121\":0.00425,\"122\":0.0156,\"124\":0.00851,\"125\":0.0156,\"126\":0.09359,\"127\":0.02269,\"128\":0.00284,\"129\":0.00567,\"130\":0.00567,\"131\":0.02411,\"132\":0.00851,\"133\":0.0156,\"134\":0.01134,\"135\":0.00284,\"136\":0.00425,\"137\":0.02836,\"138\":0.10919,\"139\":0.0312,\"140\":0.03403,\"141\":0.10777,\"142\":1.34568,\"143\":1.57114,\"144\":0.00142,_:\"4 5 6 7 8 9 10 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 33 34 35 36 37 38 40 41 42 43 44 45 46 48 49 50 51 52 53 54 55 56 59 60 61 62 63 64 65 66 72 74 76 77 78 80 81 85 87 90 91 92 93 95 96 98 101 102 113 115 123 145 146\"},F:{\"63\":0.00142,\"92\":0.00142,\"93\":0.08083,\"95\":0.00709,\"120\":0.00284,\"123\":0.00142,\"124\":0.21128,\"125\":0.09075,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/GH.js:\n Line 1: module.exports={C:{\"5\":0.00705,\"68\":0.00352,\"72\":0.00705,\"78\":0.00352,\"84\":0.00352,\"86\":0.00352,\"112\":0.00352,\"115\":0.07751,\"127\":0.01409,\"128\":0.00705,\"132\":0.00352,\"134\":0.00352,\"135\":0.00352,\"136\":0.00352,\"137\":0.01057,\"139\":0.00352,\"140\":0.02114,\"141\":0.00352,\"142\":0.01409,\"143\":0.01762,\"144\":0.04228,\"145\":0.55663,\"146\":0.59891,\"147\":0.00705,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 69 70 71 73 74 75 76 77 79 80 81 82 83 85 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 113 114 116 117 118 119 120 121 122 123 124 125 126 129 130 131 133 138 148 149 3.5 3.6\"},D:{\"11\":0.00705,\"36\":0.00352,\"40\":0.00352,\"47\":0.00352,\"49\":0.00352,\"50\":0.00352,\"51\":0.00352,\"55\":0.00352,\"58\":0.00352,\"63\":0.00352,\"64\":0.00352,\"65\":0.00352,\"66\":0.00352,\"67\":0.00352,\"68\":0.01057,\"69\":0.01409,\"70\":0.02114,\"71\":0.00705,\"72\":0.01057,\"73\":0.00352,\"74\":0.01409,\"75\":0.00705,\"76\":0.01409,\"77\":0.01057,\"78\":0.00352,\"79\":0.02818,\"80\":0.01762,\"81\":0.00705,\"83\":0.00705,\"84\":0.00705,\"85\":0.00352,\"86\":0.01409,\"87\":0.02114,\"88\":0.00352,\"89\":0.00352,\"90\":0.00352,\"91\":0.05989,\"92\":0.00705,\"93\":0.02466,\"94\":0.01057,\"95\":0.00705,\"96\":0.00705,\"97\":0.01762,\"98\":0.01409,\"99\":0.01057,\"100\":0.00352,\"101\":0.00705,\"103\":0.11978,\"104\":0.01409,\"105\":0.20433,\"106\":0.01762,\"107\":0.01409,\"108\":0.01409,\"109\":0.73983,\"110\":0.02114,\"111\":0.03171,\"112\":0.01409,\"113\":0.01409,\"114\":0.12331,\"116\":0.06694,\"117\":0.01409,\"118\":0.01409,\"119\":0.02466,\"120\":0.01762,\"121\":0.01057,\"122\":0.02818,\"123\":0.00705,\"124\":0.02466,\"125\":0.07046,\"126\":0.24309,\"127\":0.01057,\"128\":0.05989,\"129\":0.00705,\"130\":0.03875,\"131\":0.0916,\"132\":0.04932,\"133\":0.05637,\"134\":0.04932,\"135\":0.04932,\"136\":0.03875,\"137\":0.05989,\"138\":0.24661,\"139\":0.1832,\"140\":0.18672,\"141\":0.36992,\"142\":4.80537,\"143\":5.40076,\"144\":0.01762,\"145\":0.02114,_:\"4 5 6 7 8 9 10 12 13 14 15 16 17 18 1...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/GB.js:\n Line 1: module.exports={C:{\"5\":0.00879,\"48\":0.0044,\"52\":0.00879,\"59\":0.02638,\"78\":0.00879,\"103\":0.0044,\"115\":0.09673,\"125\":0.0044,\"128\":0.0044,\"133\":0.0044,\"134\":0.01319,\"135\":0.0044,\"136\":0.01319,\"137\":0.0044,\"138\":0.0044,\"139\":0.0044,\"140\":0.03078,\"141\":0.0044,\"142\":0.00879,\"143\":0.00879,\"144\":0.01759,\"145\":0.62877,\"146\":0.83543,\"147\":0.0044,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 49 50 51 53 54 55 56 57 58 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 126 127 129 130 131 132 148 149 3.5 3.6\"},D:{\"11\":0.0044,\"13\":0.0044,\"39\":0.0044,\"40\":0.0044,\"41\":0.0044,\"42\":0.0044,\"43\":0.0044,\"44\":0.0044,\"45\":0.0044,\"46\":0.0044,\"47\":0.00879,\"48\":0.00879,\"49\":0.00879,\"50\":0.0044,\"51\":0.0044,\"52\":0.00879,\"53\":0.0044,\"54\":0.0044,\"55\":0.0044,\"56\":0.00879,\"57\":0.0044,\"58\":0.0044,\"59\":0.00879,\"60\":0.0044,\"65\":0.0044,\"66\":0.10553,\"69\":0.00879,\"76\":0.0044,\"79\":0.01759,\"80\":0.0044,\"81\":0.0044,\"85\":0.00879,\"87\":0.02638,\"88\":0.00879,\"89\":0.0044,\"91\":0.01759,\"92\":0.0044,\"93\":0.00879,\"102\":0.0044,\"103\":0.09234,\"104\":0.02199,\"105\":0.01319,\"106\":0.00879,\"107\":0.02199,\"108\":0.03078,\"109\":0.25063,\"110\":0.00879,\"111\":0.02199,\"112\":0.06156,\"114\":0.02638,\"116\":0.08794,\"117\":0.01319,\"118\":0.0044,\"119\":0.03078,\"120\":0.04397,\"121\":0.00879,\"122\":0.04837,\"123\":0.0044,\"124\":0.04397,\"125\":0.03957,\"126\":0.12751,\"127\":0.02199,\"128\":0.05716,\"129\":0.01759,\"130\":0.11872,\"131\":1.12563,\"132\":0.03957,\"133\":0.04397,\"134\":0.03078,\"135\":0.03518,\"136\":0.04837,\"137\":0.06596,\"138\":0.21985,\"139\":1.04649,\"140\":0.18028,\"141\":0.4441,\"142\":7.44852,\"143\":8.23998,\"144\":0.00879,\"145\":0.0044,_:\"4 5 6 7 8 9 10 12 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 61 62 63 64 67 68 70 71 72 73 74 75 77 78 83 84 86 90 94 95 96 97 98 99 100 101 113 115 146\"},F:{\"46\"...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/FR.js:\n Line 1: module.exports={C:{\"3\":0.00477,\"5\":0.00477,\"48\":0.00477,\"51\":0.00477,\"52\":0.02384,\"54\":0.00477,\"56\":0.00477,\"59\":0.05244,\"77\":0.00477,\"78\":0.02384,\"102\":0.00477,\"113\":0.00477,\"115\":0.40996,\"121\":0.00953,\"125\":0.00953,\"127\":0.00477,\"128\":0.05244,\"130\":0.00477,\"131\":0.02384,\"132\":0.00477,\"133\":0.00477,\"134\":0.02384,\"135\":0.0143,\"136\":0.03814,\"137\":0.03337,\"138\":0.00477,\"139\":0.0143,\"140\":0.27649,\"141\":0.0429,\"142\":0.02384,\"143\":0.07627,\"144\":0.05244,\"145\":1.67798,\"146\":2.48837,\"147\":0.00477,_:\"2 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 49 50 53 55 57 58 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 103 104 105 106 107 108 109 110 111 112 114 116 117 118 119 120 122 123 124 126 129 148 149 3.5 3.6\"},D:{\"29\":0.00953,\"39\":0.0143,\"40\":0.0143,\"41\":0.0143,\"42\":0.0143,\"43\":0.0143,\"44\":0.0143,\"45\":0.0143,\"46\":0.0143,\"47\":0.0143,\"48\":0.02384,\"49\":0.03337,\"50\":0.0143,\"51\":0.0143,\"52\":0.03814,\"53\":0.0143,\"54\":0.0143,\"55\":0.0143,\"56\":0.01907,\"57\":0.0143,\"58\":0.03814,\"59\":0.0143,\"60\":0.0143,\"65\":0.00477,\"66\":0.20498,\"69\":0.00477,\"70\":0.00953,\"72\":0.00477,\"74\":0.00953,\"75\":0.00477,\"76\":0.00953,\"78\":0.00477,\"79\":0.01907,\"81\":0.00477,\"83\":0.00953,\"85\":0.00953,\"86\":0.00477,\"87\":0.02384,\"88\":0.00477,\"90\":0.00477,\"91\":0.00953,\"92\":0.00477,\"93\":0.0429,\"95\":0.00477,\"100\":0.00477,\"102\":0.00477,\"103\":0.04767,\"104\":0.00477,\"105\":0.00477,\"106\":0.00477,\"107\":0.00477,\"108\":0.00953,\"109\":0.74842,\"110\":0.00953,\"111\":0.0143,\"112\":0.00953,\"113\":0.00953,\"114\":0.0286,\"115\":0.02384,\"116\":0.16208,\"117\":0.00477,\"118\":0.0143,\"119\":0.01907,\"120\":0.0572,\"121\":0.0143,\"122\":0.03337,\"123\":0.02384,\"124\":0.0286,\"125\":0.9677,\"126\":0.06674,\"127\":0.02384,\"128\":0.08104,\"129\":0.0286,\"130\":0.08104,\"131\":0.10011,\"132\":0.33369,\"133\":0.06674,\"134\":0.06197,\"135\":0.07151,\"136\":0.11918,\"137\":0.08581,\"138\":0.25265,\"139\":0.39089,\"140\":0.21928,\"141\":0.9391,\"142\":7.77021...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/typescript/ThirdPartyNoticeText.txt:\n Line 150: For purposes of this definition, the normative portions of the Specification shall be deemed to include only architectural and interoperability requirements. Optional features in the RFC 2119 sense are considered normative unless they are specifically identified as informative. Implementation examples or any other material that merely illustrate the requirements of the Specification are informative, rather than normative.\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/typescript/lib/typescript.d.ts:\n Line 3839: PropertyAccessExpression = 211,\n Line 6561: BigIntLike = 2112,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/typescript/lib/lib.webworker.d.ts:\n Line 2281: readonly FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE_EXT: 0x8211;\n Line 5864: readonly FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE_EXT: 0x8211;\n Line 6179: readonly FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE: 0x8211;\n Line 6924: readonly FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE: 0x8211;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/ET.js:\n Line 1: module.exports={C:{\"5\":0.09711,\"43\":0.00441,\"47\":0.00441,\"52\":0.00883,\"56\":0.00441,\"57\":0.00441,\"59\":0.00441,\"60\":0.00441,\"72\":0.00883,\"112\":0.01324,\"113\":0.00441,\"115\":1.61994,\"127\":0.02648,\"128\":0.00883,\"131\":0.01766,\"133\":0.0309,\"136\":0.00441,\"139\":0.00883,\"140\":0.09711,\"141\":0.00441,\"142\":0.00441,\"143\":0.01324,\"144\":0.02207,\"145\":0.49878,\"146\":0.58265,\"147\":0.01324,_:\"2 3 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 44 45 46 48 49 50 51 53 54 55 58 61 62 63 64 65 66 67 68 69 70 71 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 114 116 117 118 119 120 121 122 123 124 125 126 129 130 132 134 135 137 138 148 149 3.5 3.6\"},D:{\"11\":0.00441,\"40\":0.00441,\"42\":0.00441,\"43\":0.01324,\"48\":0.00441,\"49\":0.00441,\"50\":0.00441,\"51\":0.00441,\"56\":0.00883,\"58\":0.01766,\"60\":0.00441,\"62\":0.00441,\"63\":0.00441,\"64\":0.00883,\"65\":0.00883,\"66\":0.01324,\"67\":0.00441,\"68\":0.00883,\"69\":0.11035,\"70\":0.00441,\"71\":0.01766,\"72\":0.00883,\"73\":0.00883,\"74\":0.00883,\"75\":0.00883,\"76\":0.00441,\"77\":0.00441,\"78\":0.00441,\"79\":0.04414,\"80\":0.02207,\"81\":0.00883,\"83\":0.00883,\"85\":0.00441,\"86\":0.01766,\"87\":0.01766,\"88\":0.00441,\"91\":0.02648,\"93\":0.00883,\"94\":0.00883,\"95\":0.00883,\"97\":0.00883,\"98\":0.01324,\"99\":0.00441,\"100\":0.00441,\"101\":0.00441,\"102\":0.00441,\"103\":0.22953,\"104\":0.20746,\"105\":0.21187,\"106\":0.21187,\"107\":0.20304,\"108\":0.20746,\"109\":0.77686,\"110\":0.20304,\"111\":0.31781,\"112\":13.4627,\"113\":0.00441,\"114\":0.02648,\"115\":0.00441,\"116\":0.41933,\"117\":0.20304,\"118\":0.00441,\"119\":0.04855,\"120\":0.2207,\"121\":0.00883,\"122\":0.14566,\"123\":0.00441,\"124\":0.21187,\"125\":0.11035,\"126\":3.95936,\"127\":0.01324,\"128\":0.01766,\"129\":0.01766,\"130\":0.01324,\"131\":0.46347,\"132\":0.11476,\"133\":0.43257,\"134\":0.0309,\"135\":0.03973,\"136\":0.04414,\"137\":0.09711,\"138\":0.19422,\"139\":0.15449,\"140\":0.11035,\"141\":0.19422,\"142\":3.54003,\"143\":5.54398,\"144\":0.02648,\"145\":0.00883,_:\"4 5 6 7 8 9 10 1...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/EG.js:\n Line 1: module.exports={C:{\"3\":0.00384,\"5\":0.01534,\"52\":0.01918,\"115\":0.3836,\"127\":0.00384,\"128\":0.00384,\"134\":0.00384,\"135\":0.00384,\"136\":0.00767,\"138\":0.01918,\"139\":0.00384,\"140\":0.0422,\"141\":0.00384,\"142\":0.00767,\"143\":0.01151,\"144\":0.01151,\"145\":0.41812,\"146\":0.67897,\"147\":0.00384,_:\"2 4 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 125 126 129 130 131 132 133 137 148 149 3.5 3.6\"},D:{\"29\":0.00384,\"40\":0.00384,\"43\":0.01918,\"47\":0.00384,\"48\":0.01534,\"49\":0.00767,\"58\":0.00384,\"62\":0.00384,\"63\":0.00384,\"66\":0.00384,\"68\":0.00384,\"69\":0.01918,\"70\":0.00767,\"71\":0.01151,\"72\":0.00384,\"73\":0.00384,\"74\":0.00767,\"75\":0.00384,\"76\":0.00384,\"77\":0.00384,\"78\":0.00384,\"79\":0.04987,\"80\":0.00767,\"81\":0.00767,\"83\":0.00384,\"84\":0.00384,\"85\":0.00767,\"86\":0.01918,\"87\":0.0537,\"88\":0.00384,\"90\":0.00384,\"91\":0.01534,\"92\":0.00384,\"93\":0.00384,\"94\":0.00384,\"95\":0.00384,\"96\":0.00384,\"97\":0.00384,\"98\":0.00384,\"99\":0.00384,\"100\":0.00384,\"101\":0.00384,\"102\":0.00384,\"103\":0.06905,\"104\":0.06521,\"105\":0.0537,\"106\":0.06138,\"107\":0.05754,\"108\":0.07672,\"109\":2.0676,\"110\":0.0537,\"111\":0.06905,\"112\":2.79644,\"114\":0.03836,\"116\":0.12275,\"117\":0.05754,\"118\":0.00767,\"119\":0.00767,\"120\":0.08056,\"121\":0.03069,\"122\":0.06521,\"123\":0.02685,\"124\":0.06521,\"125\":0.13426,\"126\":0.91297,\"127\":0.01151,\"128\":0.0422,\"129\":0.01534,\"130\":0.02302,\"131\":0.16111,\"132\":0.03452,\"133\":0.13426,\"134\":0.04987,\"135\":0.05754,\"136\":0.07672,\"137\":0.03452,\"138\":0.15728,\"139\":0.19564,\"140\":0.08439,\"141\":0.18413,\"142\":5.22847,\"143\":10.56818,\"144\":0.01534,\"145\":0.00384,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 30 31 32 33 34 35 36 37 38 39 41 42 44 45 46 50 51 52 53 54 55 56 57 59 60 61 64 65 67 ...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/DO.js:\n Line 1: module.exports={C:{\"3\":0.00553,\"4\":0.02764,\"5\":0.06081,\"52\":0.00553,\"78\":0.00553,\"115\":0.02764,\"125\":0.00553,\"128\":0.00553,\"134\":0.00553,\"135\":0.00553,\"140\":0.05528,\"141\":0.00553,\"142\":0.00553,\"143\":0.06634,\"144\":0.01658,\"145\":0.86237,\"146\":0.42566,\"147\":0.00553,\"148\":0.01106,_:\"2 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 116 117 118 119 120 121 122 123 124 126 127 129 130 131 132 133 136 137 138 139 149 3.5 3.6\"},D:{\"47\":0.00553,\"48\":0.02764,\"49\":0.00553,\"69\":0.05528,\"75\":0.00553,\"77\":0.00553,\"79\":0.00553,\"81\":0.00553,\"85\":0.00553,\"86\":0.00553,\"87\":0.00553,\"88\":0.00553,\"91\":0.00553,\"93\":0.0387,\"96\":0.00553,\"97\":0.01658,\"98\":0.00553,\"103\":0.22112,\"104\":0.21559,\"105\":0.18795,\"106\":0.19348,\"107\":0.19348,\"108\":0.20454,\"109\":0.53622,\"110\":0.19348,\"111\":0.26534,\"112\":13.33354,\"114\":0.01106,\"116\":0.4533,\"117\":0.19901,\"118\":0.00553,\"119\":0.06081,\"120\":0.24876,\"121\":0.01106,\"122\":0.07739,\"123\":0.01106,\"124\":0.23218,\"125\":1.11666,\"126\":3.51581,\"127\":0.01658,\"128\":0.04975,\"129\":0.01106,\"130\":0.01106,\"131\":0.43671,\"132\":0.09398,\"133\":0.40354,\"134\":0.02764,\"135\":0.05528,\"136\":0.04975,\"137\":0.04422,\"138\":0.26534,\"139\":0.15478,\"140\":0.08845,\"141\":0.43118,\"142\":5.55564,\"143\":9.96698,\"144\":0.01106,_:\"4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 70 71 72 73 74 76 78 80 83 84 89 90 92 94 95 99 100 101 102 113 115 145 146\"},F:{\"93\":0.02211,\"95\":0.00553,\"122\":0.01658,\"123\":0.01658,\"124\":1.05585,\"125\":0.37038,_:\"9 11 12 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 60 62 63 64 65 66 67...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/5/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2025/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2022/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2023/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2024/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2018/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2020/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2021/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2019/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/caniuse-lite/data/regions/DE.js:\n Line 1: module.exports={C:{\"48\":0.00465,\"52\":0.03723,\"59\":0.00465,\"60\":0.00465,\"68\":0.00465,\"73\":0.00465,\"77\":0.01396,\"78\":0.01862,\"88\":0.00465,\"91\":0.00465,\"98\":0.00465,\"102\":0.00465,\"103\":0.00465,\"104\":0.00465,\"109\":0.00465,\"111\":0.00931,\"113\":0.00465,\"115\":0.41421,\"118\":0.00465,\"119\":0.00465,\"120\":0.00465,\"121\":0.00465,\"122\":0.00465,\"123\":0.00931,\"125\":0.00465,\"127\":0.00931,\"128\":0.03258,\"130\":0.00465,\"131\":0.00465,\"132\":0.00931,\"133\":0.00931,\"134\":0.00931,\"135\":0.02792,\"136\":0.02327,\"137\":0.00931,\"138\":0.00931,\"139\":0.01396,\"140\":0.46075,\"141\":0.02327,\"142\":0.03258,\"143\":0.04189,\"144\":0.06981,\"145\":2.17342,\"146\":3.52773,\"147\":0.00931,_:\"2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 49 50 51 53 54 55 56 57 58 61 62 63 64 65 66 67 69 70 71 72 74 75 76 79 80 81 82 83 84 85 86 87 89 90 92 93 94 95 96 97 99 100 101 105 106 107 108 110 112 114 116 117 124 126 129 148 149 3.5 3.6\"},D:{\"39\":0.02327,\"40\":0.02327,\"41\":0.02327,\"42\":0.02327,\"43\":0.02327,\"44\":0.02327,\"45\":0.02327,\"46\":0.02327,\"47\":0.02327,\"48\":0.02327,\"49\":0.02792,\"50\":0.02327,\"51\":0.02327,\"52\":0.03723,\"53\":0.02327,\"54\":0.02327,\"55\":0.02327,\"56\":0.02327,\"57\":0.02327,\"58\":0.02792,\"59\":0.02327,\"60\":0.02327,\"63\":0.00465,\"66\":0.01862,\"74\":0.00465,\"77\":0.00465,\"79\":0.01862,\"80\":0.09308,\"81\":0.00465,\"83\":0.00465,\"84\":0.00465,\"85\":0.00465,\"86\":0.00465,\"87\":0.02792,\"88\":0.00465,\"90\":0.00465,\"91\":0.05119,\"92\":0.00465,\"93\":0.00465,\"94\":0.00465,\"95\":0.00465,\"97\":0.07446,\"98\":0.00465,\"102\":0.00465,\"103\":0.39559,\"104\":0.02327,\"105\":0.00465,\"106\":0.00465,\"107\":0.00931,\"108\":0.03258,\"109\":0.47936,\"110\":0.00931,\"111\":0.00931,\"112\":0.00931,\"113\":0.00465,\"114\":0.02792,\"115\":0.03723,\"116\":0.06981,\"117\":0.01862,\"118\":0.07912,\"119\":0.02792,\"120\":0.06981,\"121\":0.01862,\"122\":0.06516,\"123\":0.03723,\"124\":0.19547,\"125\":0.49798,\"126\":0.08377,\"127\":0.01862,\"128\":0.04654,\"129\":0.02792,\"130\":0.121,\"131\":1.64286,\"132\":0.05585,\"133\":0.05585,\"134\":0.06981,\"135\":0.05...\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2016/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2017/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/node_modules/es-abstract/2015/DateFromTime.js:\n Line 37: \t\treturn d - 211 - leap;\n\n(Results truncated: showing 100 of 1042 matches (942 hidden). Consider using a more specific path or pattern.)",
|
||
"title": "211",
|
||
"metadata": {
|
||
"matches": 1042,
|
||
"truncated": true
|
||
},
|
||
"time": {
|
||
"start": 1772500190024,
|
||
"end": 1772500190852
|
||
}
|
||
},
|
||
"id": "prt_cb13e8341001f44W5VqfdI9X8P",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73e6001WvCjwaHgAeV65J"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_vAA17gLZIo71Us9PFe3MKDLu",
|
||
"tool": "glob",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "**/*211*",
|
||
"path": "."
|
||
},
|
||
"output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md",
|
||
"title": "",
|
||
"metadata": {
|
||
"count": 1,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500190032,
|
||
"end": 1772500190052
|
||
}
|
||
},
|
||
"id": "prt_cb13e834d001Q06uDJMxmKyr4d",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73e6001WvCjwaHgAeV65J"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 13789,
|
||
"input": 13629,
|
||
"output": 160,
|
||
"reasoning": 96,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb13e868700136fuj3NlBgMaMY",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e73e6001WvCjwaHgAeV65J"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772500190940,
|
||
"completed": 1772500195454
|
||
},
|
||
"parentID": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 84052,
|
||
"input": 83930,
|
||
"output": 122,
|
||
"reasoning": 50,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb13e86dc001YZiYOh7wN1c7Zv",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"id": "prt_cb13e97600018NL1itgBcN0OHA",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e86dc001YZiYOh7wN1c7Zv"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Confirming task completion**",
|
||
"time": {
|
||
"start": 1772500195170,
|
||
"end": 1772500195379
|
||
},
|
||
"id": "prt_cb13e97620012MP2MbYoQN5Rlt",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e86dc001YZiYOh7wN1c7Zv"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_Mwb4wUH2UnZBWSVNjqO4jF9k",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md",
|
||
"offset": 1,
|
||
"limit": 400
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/211-custom-ip-selection.md</path>\n<type>file</type>\n<content>1: # Issue #211: 转发自定义监听IP / 隧道指定连接IP\n2: \n3: ## 需求总结\n4: 1. **节点**: 高级配置增加\"额外IP地址\"字段(逗号分隔)\n5: 2. **转发**: 创建/编辑时可指定入口监听IP\n6: 3. **隧道**: 配置出口节点时可指定连接IP\n7: \n8: ---\n9: \n10: ## 任务清单\n11: \n12: ### 后端\n13: - [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段\n14: - [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs\n15: - [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip\n16: - [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)\n17: - [x] 5. Handler - 转发创建/更新处理 inIp 参数\n18: - [x] 6. Handler - 隧道出口节点处理 connectIp 参数\n19: - [x] 7. Handler - 节点API返回 extraIPs 字段\n20: \n21: ### 前端\n22: - [x] 8. 节点编辑页 - 高级配置增加\"额外IP\"输入\n23: - [x] 9. 转发编辑弹窗 - 增加\"监听IP\"下拉选择\n24: - [x] 10. 隧道配置页 - 出口节点增加\"连接IP\"输入\n25: \n26: ---\n27: \n28: ## 完成进度\n29: - 开始时间: 2026-03-02\n30: - 完成时间: 2026-03-02\n31: - 完成任务: 10/10\n32: - 后端完成: ✅\n33: - 前端完成: ✅\n\n(End of file - total 33 lines)\n</content>",
|
||
"title": "211-custom-ip-selection.md",
|
||
"metadata": {
|
||
"preview": "# Issue #211: 转发自定义监听IP / 隧道指定连接IP\n\n## 需求总结\n1. **节点**: 高级配置增加\"额外IP地址\"字段(逗号分隔)\n2. **转发**: 创建/编辑时可指定入口监听IP\n3. **隧道**: 配置出口节点时可指定连接IP\n\n---\n\n## 任务清单\n\n### 后端\n- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段\n- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs\n- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip\n- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP)\n- [x] 5. Handler - 转发创建/更新处理 inIp 参数\n- [x] 6. Handler - 隧道出口节点处理 connectIp 参数\n- [x] 7. Handler - 节点API返回 extraIPs 字段\n",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772500195282,
|
||
"end": 1772500195285
|
||
}
|
||
},
|
||
"id": "prt_cb13e9780001dTMWZNL3kMq8ct",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e86dc001YZiYOh7wN1c7Zv"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 84052,
|
||
"input": 83930,
|
||
"output": 122,
|
||
"reasoning": 50,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb13e9834001mgBtD5bTRV8Skw",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e86dc001YZiYOh7wN1c7Zv"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772500195455,
|
||
"completed": 1772500204111
|
||
},
|
||
"parentID": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 84960,
|
||
"input": 84528,
|
||
"output": 432,
|
||
"reasoning": 188,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb13e987f001d1IO8hKwqO723z",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"id": "prt_cb13eaef6001DLmQK6hEoqktnH",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning comprehensive task verification**",
|
||
"time": {
|
||
"start": 1772500201207,
|
||
"end": 1772500204045
|
||
},
|
||
"id": "prt_cb13eaef7001o6gFam8kuKrTqd",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_Eb0W3zXWjSUhxk2829J76PUH",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "extraIPs|extra_ips|ExtraIPs",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 12 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 640: \t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 199: func (r *Repository) CreateNode(name, secret, serverIP string, serverIPV4, serverIPV6, port, interfaceName, version interface{}, httpFlag, tlsFlag, socksFlag int, now int64, status int, tcpAddr, udpAddr string, inx, isRemote int, remoteURL, remoteToken, remoteConfig, extraIPs interface{}) error {\n Line 209: \t\tExtraIPs: nullStringFromInterface(extraIPs),\n Line 242: func (r *Repository) UpdateNode(id int64, name, serverIP string, serverIPV4, serverIPV6, port, interfaceName, extraIPs interface{}, httpFlag, tlsFlag, socksFlag int, tcpAddr, udpAddr string, now int64) error {\n Line 253: \t\t\t\"extra_ips\": nullStringFromInterface(extraIPs),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 184: \tif n.ExtraIPs.Valid {\n Line 185: \t\trec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 67: \tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n Line 343: \tExtraIPs string `json:\"extraIPs,omitempty\"`\n Line 527: \tExtraIPs string\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 274: \t\tnullableText(asString(req[\"extraIPs\"])),\n Line 326: \t\tnullableText(asString(req[\"extraIPs\"])),",
|
||
"title": "extraIPs|extra_ips|ExtraIPs",
|
||
"metadata": {
|
||
"matches": 12,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500204020,
|
||
"end": 1772500204049
|
||
}
|
||
},
|
||
"id": "prt_cb13eb9ef001M31qrPVYu09wO9",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_IIi1rbFuXouSNEpZuGTWd198",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "inIp|in_ip|connectIp|connect_ip",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 83 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 738: \t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n Line 816: \t\t\t\"inIp\": nullableString(t.InIP),\n Line 869: \t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n Line 904: \t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n Line 906: \t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n Line 2083: \t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n Line 2733: \tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n Line 2742: \t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 387: \t\t\t\"in_ip\": nullStringFromInterface(inIP),\n Line 400: func (r *Repository) CreateChainTunnelTx(tx *gorm.DB, tunnelID int64, chainType string, nodeID int64, port sql.NullInt64, strategy string, inx int, protocol string, connectIp string) error {\n Line 412: \t\tConnectIP: sql.NullString{String: connectIp, Valid: connectIp != \"\"},\n Line 1180: func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {\n Line 1210: \t\t\t\tInIP: sql.NullString{String: inIp, Valid: inIp != \"\"},\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 303: \t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip\").\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 55: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 122: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 138: \tConnectIP sql.NullString `gorm:\"column:connect_ip;type:varchar(45)\"`\n Line 372: \tInIP string `json:\"inIp,omitempty\"`\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/mutations.go:\n Line 478: \tinIP := asString(req[\"inIp\"])\n Line 695: \tinIp := buildTunnelInIP(runtimeState.InNodes, runtimeState.Nodes, ipPreference)\n Line 714: \t\tinIp,\n Line 1181: \tinIp := strings.TrimSpace(asString(req[\"inIp\"]))\n Line 1182: \tforwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req[\"strategy\"]), \"fifo\"), now, inx, entryNodes, port, inIp, nullableInt(speedID))\n Line 1286: \tinIp := asString(req[\"inIp\"])\n Line 1303: \tif err := h.replaceForwardPorts(id, tunnelID, port, inIp); err != nil {\n Line 2035: \t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2071: \t\t\t\t\tConnectIP: asString(item[\"connectIp\"]),\n Line 2661: func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, connectIp string) (string, error) {\n Line 2665: \tif strings.TrimSpace(connectIp) != \"\" {\n Line 2666: \t\treturn strings.TrimSpace(connectIp), nil\n Line 2820: \t\tconnectIp := asString(n[\"connectIp\"])\n Line 2830: \t\t\tconnectIp,\n Line 2850: \t\t\tconnectIp := asString(n[\"connectIp\"])\n Line 2860: \t\t\t\tconnectIp,\n Line 3017: func (h *Handler) replaceForwardPorts(forwardID, tunnelID int64, port int, inIp string) error {\n Line 3032: \t\t}{NodeID: nid, Port: port, InIP: inIp}\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/dual_stack_test.go:\n Line 15: \t// Empty connectIp should be ignored, IP preference takes effect\n Line 21: \t\tt.Fatalf(\"empty connectIp should be ignored (v4 preference applies), got %q\", host)\n Line 23: \t// Non-empty connectIp should override IP preference\n Line 29: \t\tt.Fatalf(\"connectIp should override v6 preference, got %q\", host)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/control_plane.go:\n Line 1110: func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {\n Line 1114: \thost, err := selectTunnelDialHost(fromNode, targetNode, ipPreference, connectIp)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_visibility_contract_test.go:\n Line 28: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_ip_preference_contract_test.go:\n Line 98: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 134: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 184: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 210: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 252: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_create_contract_test.go:\n Line 96: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/speed_limit_contract_test.go:\n Line 297: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/group_permission_contract_test.go:\n Line 26: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 98: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go:\n Line 140: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n Line 344: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 483: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 655: \t\t\tin_ip TEXT\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/limiter_sync_failure_contract_test.go:\n Line 36: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 115: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 194: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 277: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 355: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 463: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/federation_forward_flow_linkage_contract_test.go:\n Line 61: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 234: \t\t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 413: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/forward_contract_test.go:\n Line 30: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 212: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 318: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 326: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 428: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 501: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 632: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/diagnosis_contract_test.go:\n Line 31: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 205: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 386: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/jobs_test.go:\n Line 80: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/handler.go:\n Line 925: \t\t\t\"inIp\": f.InIP,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/flow_policy_federation_test.go:\n Line 102: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 215: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_share_test.go:\n Line 586: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 874: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, \"consumer-tunnel-a\", 2, \"tls\", 1, now, now, 1, \"\", 0).Error; err != nil {\n Line 879: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, \"consumer-tunnel-b\", 2, \"tls\", 1, now, now, 1, \"\", 0).Error; err != nil {\n Line 967: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)",
|
||
"title": "inIp|in_ip|connectIp|connect_ip",
|
||
"metadata": {
|
||
"matches": 83,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500204040,
|
||
"end": 1772500204051
|
||
}
|
||
},
|
||
"id": "prt_cb13eba03001RcFvmMzIq47Iqu",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_qSOXTerKFBnVYI6XUOo9FVlk",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "extraIPs|inIp|connectIp",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src",
|
||
"include": "*.{ts,tsx}"
|
||
},
|
||
"output": "Found 70 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel.tsx:\n Line 73: connectIp?: string; // 连接IP(多IP节点指定连接地址)\n Line 84: inIp: string;\n Line 101: extraIPs?: string;\n Line 113: inIp: string; // 入口IP\n Line 171: (node.extraIPs || \"\")\n Line 296: inIp: tunnel.inIp\n Line 297: ? tunnel.inIp\n Line 392: const updateChainConnectIp = (groupIndex: number, connectIp: string) => {\n Line 398: connectIp,\n Line 479: const inIpString = form.inIp\n Line 487: inIp: inIpString,\n Line 858: (t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),\n Line 1429: errorMessage={errors.inIp}\n Line 1430: isInvalid={!!errors.inIp}\n Line 1435: value={form.inIp}\n Line 1438: setForm((prev) => ({ ...prev, inIp: e.target.value }))\n Line 1599: ? groupNodes[0].connectIp || \"\"\n Line 2146: ? form.outNodeId[0].connectIp || \"__default__\"\n Line 2166: connectIp: value,\n Line 2175: connectIp: value,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/forward.tsx:\n Line 102: inIp: string;\n Line 122: inIp?: string;\n Line 133: extraIPs?: string;\n Line 142: inIp: string;\n Line 564: inIp: \"\",\n Line 610: (node.extraIPs || \"\")\n Line 632: (tunnel.inIp || \"\")\n Line 1256: inIp: \"\",\n Line 1275: inIp: forward.inIp || \"\",\n Line 1336: inIp: options.includes(prev.inIp) ? prev.inIp : \"\",\n Line 1364: inIp: form.inIp || undefined,\n Line 1376: inIp: form.inIp || undefined,\n Line 2755: hasMultipleAddresses(forward.inIp)\n Line 2759: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 2762: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 2765: {formatInAddress(forward.inIp, forward.inPort)}\n Line 2974: hasMultipleAddresses(forward.inIp)\n Line 2978: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 2981: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 2984: {formatInAddress(forward.inIp, forward.inPort)}\n Line 3173: hasMultipleAddresses(forward.inIp)\n Line 3177: title={formatInAddress(forward.inIp, forward.inPort)}\n Line 3180: showAddressModal(forward.inIp, forward.inPort, \"入口端口\")\n Line 3189: {formatInAddress(forward.inIp, forward.inPort)}\n Line 3192: {hasMultipleAddresses(forward.inIp) && (\n Line 4136: selectedKeys={[form.inIp || \"__default__\"]}\n Line 4143: inIp: selectedKey === \"__default__\" ? \"\" : selectedKey,\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/api/types.ts:\n Line 47: inIp?: string;\n Line 220: extraIPs?: string;\n Line 234: connectIp?: string;\n Line 246: inIp?: string;\n Line 284: inIp?: string;\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/node.tsx:\n Line 79: extraIPs?: string;\n Line 113: extraIPs: string;\n Line 188: extraIPs: \"\",\n Line 596: extraIPs: node.extraIPs || \"\",\n Line 824: extraIPs: form.extraIPs,\n Line 888: extraIPs: \"\",\n Line 1650: value={form.extraIPs}\n Line 1653: setForm((prev) => ({ ...prev, extraIPs: e.target.value }))\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/tunnel/form.ts:\n Line 27: inIp: \"\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/dashboard/use-dashboard-data.ts:\n Line 40: inIp: string;\n Line 161: inIp: item.inIp || \"\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend/src/pages/dashboard.tsx:\n Line 324: // inIp 已经包含完整的 IP:Port 组合\n Line 332: // inIp 只包含IP,需要添加端口(兼容旧数据)\n Line 859: className={`block px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 rounded font-mono text-xs truncate ${hasMultipleIps(forward.inIp) ? \"cursor-pointer hover:bg-green-200 dark:hover:bg-green-500/30\" : \"\"}`}\n Line 860: disabled={!hasMultipleIps(forward.inIp)}\n Line 862: forward.inIp,\n Line 868: forward.inIp,\n Line 874: {formatInAddress(forward.inIp, forward.inPort)}",
|
||
"title": "extraIPs|inIp|connectIp",
|
||
"metadata": {
|
||
"matches": 70,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500204044,
|
||
"end": 1772500204055
|
||
}
|
||
},
|
||
"id": "prt_cb13eba09001RWZruy8MFcC8uK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 84960,
|
||
"input": 84528,
|
||
"output": 432,
|
||
"reasoning": 188,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb13eba17001Y33RF6T0AJyqO9",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13e987f001d1IO8hKwqO723z"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772500204112,
|
||
"completed": 1772500224808
|
||
},
|
||
"parentID": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91205,
|
||
"input": 90565,
|
||
"output": 640,
|
||
"reasoning": 437,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb13eba50001YaAa7Kv1fUkkXC",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"id": "prt_cb13ef1750019BdG4xDsjpj3yU",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13eba50001YaAa7Kv1fUkkXC"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning targeted test and build checks**",
|
||
"time": {
|
||
"start": 1772500218231,
|
||
"end": 1772500219768
|
||
},
|
||
"id": "prt_cb13ef177001BJsEUA6umH6lPh",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13eba50001YaAa7Kv1fUkkXC"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_syzfqdG2Cf94jIzlaBiq92CY",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./internal/http/handler -run \"TestSelectTunnelDialHost|TestBuildTunnelInIP|TestDefaultTunnelInIP|TestCreateTunnel\"",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Runs targeted backend handler tests"
|
||
},
|
||
"output": "ok \tgo-backend/internal/http/handler\t0.546s\n",
|
||
"title": "Runs targeted backend handler tests",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/internal/http/handler\t0.546s\n",
|
||
"exit": 0,
|
||
"description": "Runs targeted backend handler tests",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500219742,
|
||
"end": 1772500222272
|
||
}
|
||
},
|
||
"id": "prt_cb13ef75a001SMG6m897G7Dmrc",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13eba50001YaAa7Kv1fUkkXC"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_uJXZGTdjo3RJHpvHXLWMOlFo",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "npm run build",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/vite-frontend",
|
||
"description": "Builds frontend project for verification"
|
||
},
|
||
"output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3179 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-D7vS0DQY.css 105.58 kB │ gzip: 16.60 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-mjdXO_rb.js 3,928.43 kB │ gzip: 810.35 kB\n✓ built in 667ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (3954.92 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n",
|
||
"title": "Builds frontend project for verification",
|
||
"metadata": {
|
||
"output": "\n> flvx@0.0.0 build\n> tsc && vite build\n\nrolldown-vite v7.3.1 building client environment for production...\n\u001b[2K\rtransforming...✓ 3179 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/manifest.webmanifest 0.43 kB\ndist/index.html 7.51 kB │ gzip: 2.50 kB\ndist/assets/index-D7vS0DQY.css 105.58 kB │ gzip: 16.60 kB\ndist/assets/workbox-window.prod.es5-Bm4xbD7U.js 8.29 kB │ gzip: 2.81 kB\ndist/assets/index-mjdXO_rb.js 3,928.43 kB │ gzip: 810.35 kB\n✓ built in 667ms\n\nPWA v1.2.0\nmode generateSW\nprecache 9 entries (3954.92 KiB)\nfiles generated\n dist/sw.js\n dist/workbox-8c29f6e4.js\n",
|
||
"exit": 0,
|
||
"description": "Builds frontend project for verification",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772500219759,
|
||
"end": 1772500224741
|
||
}
|
||
},
|
||
"id": "prt_cb13ef76d001sgwj8DXXnA8qDa",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13eba50001YaAa7Kv1fUkkXC"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91205,
|
||
"input": 90565,
|
||
"output": 640,
|
||
"reasoning": 437,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb13f0ae6001RiQChkBygffEro",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13eba50001YaAa7Kv1fUkkXC"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772500224809,
|
||
"completed": 1772500236759
|
||
},
|
||
"parentID": "msg_cb13e73bc001fBbvd7bAbBIy0D",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91698,
|
||
"input": 91042,
|
||
"output": 656,
|
||
"reasoning": 140,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb13f0b29001KiGdQFB2YmatxG",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"id": "prt_cb13f21fc001AG392m3ia0sbMO",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13f0b29001KiGdQFB2YmatxG"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Confirming task completion with caveats**",
|
||
"time": {
|
||
"start": 1772500230653,
|
||
"end": 1772500236647
|
||
},
|
||
"id": "prt_cb13f21fd001mpCDrxBV4EFb01",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13f0b29001KiGdQFB2YmatxG"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "已把 `211` 相关内容做了**全量复核**(文档清单 + 代码落点 + 可执行验证),结论是:**10/10 任务均已落地,功能链路已完成**。\n\n- 任务清单文件 `211-custom-ip-selection.md` 显示后端 7 项、前端 3 项全部勾选完成(`10/10`)。\n- 后端字段与模型已到位:`extra_ips` / `in_ip` / `connect_ip` 在模型、Repo、Handler 都有实现,关键位置见 `go-backend/internal/store/model/model.go`、`go-backend/internal/store/repo/repository_mutations.go`、`go-backend/internal/http/handler/mutations.go`。\n- 节点“额外IP”链路已打通:创建/更新入参处理与返回映射都存在,见 `go-backend/internal/http/handler/mutations.go`、`go-backend/internal/store/repo/repository.go`、`vite-frontend/src/pages/node.tsx`。\n- 转发“监听IP(inIp)”链路已打通:前端表单、请求参数、后端入库与读取逻辑均存在,见 `vite-frontend/src/pages/forward.tsx`、`go-backend/internal/http/handler/mutations.go`、`go-backend/internal/store/repo/repository_mutations.go`。\n- 隧道“连接IP(connectIp)”链路已打通:前端配置、后端选择拨号目标逻辑都有,见 `vite-frontend/src/pages/tunnel.tsx`、`go-backend/internal/http/handler/mutations.go`(`selectTunnelDialHost`)。\n- 功能验证结果:\n - 后端相关测试通过:`go test ./internal/http/handler -run \"TestSelectTunnelDialHost|TestBuildTunnelInIP|TestDefaultTunnelInIP|TestCreateTunnel\"`。\n - 前端构建通过:`vite-frontend` 下 `npm run build` 成功。\n\n结论:**211 任务“全量完成 + 功能完成”成立**。 \n如果你要,我下一步可以直接给你一份“按页面点击路径”的手工验收用例(节点/转发/隧道各 3~5 条),用于你上线前最终验收。",
|
||
"time": {
|
||
"start": 1772500236653,
|
||
"end": 1772500236653
|
||
},
|
||
"id": "prt_cb13f226b001GvoFscPEuQscjo",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13f0b29001KiGdQFB2YmatxG"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "4bc79fac3193a138f958ecdb3204729fd3004e76",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91698,
|
||
"input": 91042,
|
||
"output": 656,
|
||
"reasoning": 140,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb13f3972001E7AiXIPjDxhyO5",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb13f0b29001KiGdQFB2YmatxG"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772503828356
|
||
},
|
||
"summary": {
|
||
"diffs": [
|
||
{
|
||
"file": "go-backend/internal/store/repo/repository.go",
|
||
"before": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"after": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"additions": 1,
|
||
"deletions": 1,
|
||
"status": "modified"
|
||
}
|
||
]
|
||
},
|
||
"agent": "build",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "添加额外ip的时候报错:SQL logic error: no such column: extra_ips (1)",
|
||
"id": "prt_cb1760739002J4IgQeoIknIbV3",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1760739001ZLmOhf2wKkCtNC"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503828366,
|
||
"completed": 1772503850991
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91866,
|
||
"input": 91661,
|
||
"output": 205,
|
||
"reasoning": 48,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb176078e001H5Fk3bry8ssAZg",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb1765c48001x8sLsIIGqVTWkj",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176078e001H5Fk3bry8ssAZg"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Inspecting migration for column addition**",
|
||
"time": {
|
||
"start": 1772503850057,
|
||
"end": 1772503850892
|
||
},
|
||
"id": "prt_cb1765c4900124Szzv34cZSOFP",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176078e001H5Fk3bry8ssAZg"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_05YC01lwDozCwfaKOkozk0Xu",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "extra_ips|connect_ip|in_ip",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 50 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 2083: \t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n Line 2733: \tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n Line 2742: \t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 253: \t\t\t\"extra_ips\": nullStringFromInterface(extraIPs),\n Line 387: \t\t\t\"in_ip\": nullStringFromInterface(inIP),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 303: \t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip\").\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 55: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 67: \tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n Line 122: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n Line 138: \tConnectIP sql.NullString `gorm:\"column:connect_ip;type:varchar(45)\"`\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_visibility_contract_test.go:\n Line 28: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_ip_preference_contract_test.go:\n Line 98: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 134: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 184: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 210: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 252: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/tunnel_create_contract_test.go:\n Line 96: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/speed_limit_contract_test.go:\n Line 297: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/group_permission_contract_test.go:\n Line 26: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 98: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/limiter_sync_failure_contract_test.go:\n Line 36: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 115: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 194: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 277: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 355: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 463: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go:\n Line 140: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n Line 344: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 483: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 655: \t\t\tin_ip TEXT\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/federation_forward_flow_linkage_contract_test.go:\n Line 61: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 234: \t\t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 413: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/forward_contract_test.go:\n Line 30: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 212: \t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 318: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 326: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 428: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 501: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 632: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/diagnosis_contract_test.go:\n Line 31: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 205: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx, ip_preference)\n Line 386: \t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/jobs_test.go:\n Line 80: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/flow_policy_federation_test.go:\n Line 102: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 215: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/http/handler/federation_share_test.go:\n Line 586: \t\tINSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n Line 874: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, \"consumer-tunnel-a\", 2, \"tls\", 1, now, now, 1, \"\", 0).Error; err != nil {\n Line 879: \tif err := r.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, \"consumer-tunnel-b\", 2, \"tls\", 1, now, now, 1, \"\", 0).Error; err != nil {\n Line 967: \t\tINSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)",
|
||
"title": "extra_ips|connect_ip|in_ip",
|
||
"metadata": {
|
||
"matches": 50,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503850670,
|
||
"end": 1772503850697
|
||
}
|
||
},
|
||
"id": "prt_cb1765e7b001g33mL61kHuFcuM",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176078e001H5Fk3bry8ssAZg"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_9bdFi3KzvucX1Fd65a5NTPeQ",
|
||
"tool": "glob",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "**/*migrat*.go",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend"
|
||
},
|
||
"output": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go",
|
||
"title": "go-backend",
|
||
"metadata": {
|
||
"count": 2,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503850788,
|
||
"end": 1772503850809
|
||
}
|
||
},
|
||
"id": "prt_cb1765eef0014bA9nEhvWc60IK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176078e001H5Fk3bry8ssAZg"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 91866,
|
||
"input": 91661,
|
||
"output": 205,
|
||
"reasoning": 48,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1765f8f001G7b9UzpAInAJoc",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176078e001H5Fk3bry8ssAZg"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503850993,
|
||
"completed": 1772503859249
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 94818,
|
||
"input": 94612,
|
||
"output": 206,
|
||
"reasoning": 33,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1765ff1001c06T2rR5WaWfEv",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb1767adf0014CmP6VxMbguFeN",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1765ff1001c06T2rR5WaWfEv"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Searching for migration code**",
|
||
"time": {
|
||
"start": 1772503857889,
|
||
"end": 1772503859163
|
||
},
|
||
"id": "prt_cb1767ae1001qKqGgkKLDu3Twu",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1765ff1001c06T2rR5WaWfEv"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_xyUwg9xo6uANs8qgzCK3CJ0c",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "AutoMigrate|ALTER TABLE|add column|extra_ips|connect_ip",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 10 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 187: \t\treturn db.AutoMigrate(models...)\n Line 205: \t\tif err := db.AutoMigrate(item); err != nil {\n Line 216: // GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n Line 248: \t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n Line 2574: \tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n Line 2662: \t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 253: \t\t\t\"extra_ips\": nullStringFromInterface(extraIPs),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 303: \t\tSelect(\"chain_tunnel.chain_type, chain_tunnel.inx, chain_tunnel.node_id, chain_tunnel.port, node.name, chain_tunnel.protocol, chain_tunnel.strategy, chain_tunnel.connect_ip\").\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 67: \tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n Line 138: \tConnectIP sql.NullString `gorm:\"column:connect_ip;type:varchar(45)\"`",
|
||
"title": "AutoMigrate|ALTER TABLE|add column|extra_ips|connect_ip",
|
||
"metadata": {
|
||
"matches": 10,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503859143,
|
||
"end": 1772503859165
|
||
}
|
||
},
|
||
"id": "prt_cb1767fb6001fIDatkcpjlE0oh",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1765ff1001c06T2rR5WaWfEv"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_kn1MzK7re4oJ7Elm6UsN99nC",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "Migrate|migrate",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 36 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 94: \tif err := autoMigrateAll(db); err != nil {\n Line 96: \t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n Line 101: \tif err := migrateSchema(db); err != nil {\n Line 135: \tif err := autoMigrateAll(db); err != nil {\n Line 137: \t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n Line 142: \tif err := migrateSchema(db); err != nil {\n Line 161: func autoMigrateAll(db *gorm.DB) error {\n Line 187: \t\treturn db.AutoMigrate(models...)\n Line 205: \t\tif err := db.AutoMigrate(item); err != nil {\n Line 216: // GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n Line 2473: var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\n Line 2474: var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n Line 2489: func migrateSchema(db *gorm.DB) error {\n Line 2527: \t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n Line 2533: \t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n Line 2542: func migrateViteConfigValueColumnType(db *gorm.DB) error {\n Line 2581: func migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go:\n Line 13: func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {\n Line 44: \tif err := migrateSchema(db); err != nil {\n Line 45: \t\tt.Fatalf(\"migrateSchema: %v\", err)\n Line 52: func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {\n Line 82: \terr = migrateSchema(db)\n Line 88: func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {\n Line 118: \toriginalMigrate := migrateViteConfigValueColumnTypeFn\n Line 119: \tmigrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {\n Line 124: \t\tmigrateViteConfigValueColumnTypeFn = originalMigrate\n Line 127: \tif err := migrateSchema(db); err != nil {\n Line 128: \t\tt.Fatalf(\"migrateSchema: %v\", err)\n Line 136: func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {\n Line 166: \toriginalMigrate := migrateViteConfigValueColumnTypeFn\n Line 167: \tmigrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {\n Line 171: \t\tmigrateViteConfigValueColumnTypeFn = originalMigrate\n Line 174: \terr = migrateSchema(db)\n Line 180: func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {\n Line 229: \tif err := migrateSchema(db); err != nil {\n Line 230: \t\tt.Fatalf(\"migrateSchema: %v\", err)",
|
||
"title": "Migrate|migrate",
|
||
"metadata": {
|
||
"matches": 36,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503859158,
|
||
"end": 1772503859175
|
||
}
|
||
},
|
||
"id": "prt_cb1767fd0001N2wcbwveGuqlDW",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1765ff1001c06T2rR5WaWfEv"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 94818,
|
||
"input": 94612,
|
||
"output": 206,
|
||
"reasoning": 33,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1767fe80013CKUzlGpViWsiX",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1765ff1001c06T2rR5WaWfEv"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503859251,
|
||
"completed": 1772503865546
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 96193,
|
||
"input": 96093,
|
||
"output": 100,
|
||
"reasoning": 24,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1768032001LccLsv1mXx6pet",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb1769770001QwT2ITfLNFq6aK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1768032001LccLsv1mXx6pet"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Inspecting migration handling**",
|
||
"time": {
|
||
"start": 1772503865201,
|
||
"end": 1772503865481
|
||
},
|
||
"id": "prt_cb1769771001rpNyPER8RuROu1",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1768032001LccLsv1mXx6pet"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_N8Yrr9WQl7gPYxvZ8lmE4pK9",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"offset": 140,
|
||
"limit": 160
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go</path>\n<type>file</type>\n<content>140: \tseedData(db)\n141: \n142: \tif err := migrateSchema(db); err != nil {\n143: \t\t_ = sqlDB.Close()\n144: \t\treturn nil, err\n145: \t}\n146: \n147: \treturn &Repository{db: db}, nil\n148: }\n149: \n150: func (r *Repository) Close() error {\n151: \tif r == nil || r.db == nil {\n152: \t\treturn nil\n153: \t}\n154: \tsqlDB, err := r.db.DB()\n155: \tif err != nil {\n156: \t\treturn err\n157: \t}\n158: \treturn sqlDB.Close()\n159: }\n160: \n161: func autoMigrateAll(db *gorm.DB) error {\n162: \tmodels := []interface{}{\n163: \t\t&model.User{},\n164: \t\t&model.Forward{},\n165: \t\t&model.ForwardPort{},\n166: \t\t&model.Node{},\n167: \t\t&model.SpeedLimit{},\n168: \t\t&model.StatisticsFlow{},\n169: \t\t&model.Tunnel{},\n170: \t\t&model.ChainTunnel{},\n171: \t\t&model.UserTunnel{},\n172: \t\t&model.TunnelGroup{},\n173: \t\t&model.UserGroup{},\n174: \t\t&model.TunnelGroupTunnel{},\n175: \t\t&model.UserGroupUser{},\n176: \t\t&model.GroupPermission{},\n177: \t\t&model.GroupPermissionGrant{},\n178: \t\t&model.ViteConfig{},\n179: \t\t&model.PeerShare{},\n180: \t\t&model.PeerShareRuntime{},\n181: \t\t&model.FederationTunnelBinding{},\n182: \t\t&model.Announcement{},\n183: \t\t&model.SchemaVersion{},\n184: \t}\n185: \n186: \tif db.Dialector.Name() != \"sqlite\" {\n187: \t\treturn db.AutoMigrate(models...)\n188: \t}\n189: \n190: \tm := db.Migrator()\n191: \thasNode := m.HasTable(&model.Node{})\n192: \thasTunnel := m.HasTable(&model.Tunnel{})\n193: \n194: \tfor _, item := range models {\n195: \t\tif hasNode {\n196: \t\t\tif _, ok := item.(*model.Node); ok {\n197: \t\t\t\tcontinue\n198: \t\t\t}\n199: \t\t}\n200: \t\tif hasTunnel {\n201: \t\t\tif _, ok := item.(*model.Tunnel); ok {\n202: \t\t\t\tcontinue\n203: \t\t\t}\n204: \t\t}\n205: \t\tif err := db.AutoMigrate(item); err != nil {\n206: \t\t\treturn err\n207: \t\t}\n208: \t}\n209: \n210: \treturn nil\n211: }\n212: \n213: // preparePostgresLegacySchema renames unique constraints that were created by\n214: // the old schema.sql (which used inline UNIQUE column syntax) to the names\n215: // expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n216: // GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n217: // don't exist under that name, crashing startup on upgraded PostgreSQL installs.\n218: func preparePostgresLegacySchema(db *gorm.DB) error {\n219: \tif db == nil || db.Dialector.Name() != \"postgres\" {\n220: \t\treturn nil\n221: \t}\n222: \n223: \ttype rename struct{ table, oldName, newName string }\n224: \trenames := []rename{\n225: \t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n226: \t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n227: \t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n228: \t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n229: \t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n230: \t}\n231: \n232: \tfor _, r := range renames {\n233: \t\tvar count int64\n234: \t\tif err := db.Raw(\n235: \t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n236: \t\t\t WHERE constraint_schema = current_schema()\n237: \t\t\t AND table_name = ?\n238: \t\t\t AND constraint_name = ?\n239: \t\t\t AND constraint_type = 'UNIQUE'`,\n240: \t\t\tr.table, r.oldName,\n241: \t\t).Scan(&count).Error; err != nil {\n242: \t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n243: \t\t}\n244: \t\tif count == 0 {\n245: \t\t\tcontinue\n246: \t\t}\n247: \t\tif err := db.Exec(\n248: \t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n249: \t\t).Error; err != nil {\n250: \t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n251: \t\t}\n252: \t}\n253: \treturn nil\n254: }\n255: \n256: func prepareSQLiteLegacyColumns(db *gorm.DB) error {\n257: \tif db == nil || db.Dialector.Name() != \"sqlite\" {\n258: \t\treturn nil\n259: \t}\n260: \tm := db.Migrator()\n261: \n262: \tif m.HasTable(&model.Node{}) {\n263: \t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n264: \t\t\tif m.HasColumn(&model.Node{}, field) {\n265: \t\t\t\tcontinue\n266: \t\t\t}\n267: \t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n268: \t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n269: \t\t\t}\n270: \t\t}\n271: \t}\n272: \n273: \tif m.HasTable(&model.Tunnel{}) {\n274: \t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n275: \t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n276: \t\t\t\tcontinue\n277: \t\t\t}\n278: \t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n279: \t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n280: \t\t\t}\n281: \t\t}\n282: \t}\n283: \n284: \treturn nil\n285: }\n286: \n287: func seedData(db *gorm.DB) {\n288: \tadminUser := model.User{\n289: \t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n290: \t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n291: \t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n292: \t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n293: \t}\n294: \tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n295: \n296: \tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n297: \tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n298: }\n299: \n\n(Showing lines 140-299 of 2846. Use offset=300 to continue.)\n</content>\n\n<system-reminder>\nInstructions from: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/AGENTS.md\n# GO BACKEND KNOWLEDGE BASE\n\n## OVERVIEW\nGo-based Admin API for FLVX. Replaced legacy Spring Boot backend.\n**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).\n\n## STRUCTURE\n```\ngo-backend/\n├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket\n├── internal/\n│ ├── http/ # HTTP layer\n│ │ ├── router.go # Routes (NewServeMux) + Middleware chain\n│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)\n│ │ ├── middleware/ # JWT, CORS, Logging, Recover\n│ │ └── response/ # JSON response helpers\n│ ├── store/\n│ │ ├── model/model.go # GORM model structs (single source of truth)\n│ │ └── repo/ # Data Access Layer (Repository pattern, GORM)\n│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)\n│ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)\n│ │ ├── repository_federation.go # Federation-specific queries\n│ │ ├── repository_flow.go # Flow/forward status queries\n│ │ ├── repository_control.go # Control plane queries\n│ │ └── repository_groups.go # Group management queries\n│ └── auth/ # Auth logic\n├── tests/contract/ # Integration/contract tests (14 tests)\n├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)\n└── Makefile # Build commands\n```\n\n## WHERE TO LOOK\n| Task | Location | Notes |\n|------|----------|-------|\n| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |\n| **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |\n| **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |\n| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |\n| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |\n| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |\n\n## CONVENTIONS\n- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.\n- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.\n- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.\n- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.\n- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).\n- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).\n- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.\n- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).\n- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.\n- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.\n\n## ANTI-PATTERNS\n- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.\n- **DO NOT CHANGE** handler signatures without updating `router.go`.\n- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).\n- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.\n\n## COMMANDS\n```bash\ncd go-backend\ngo run ./cmd/paneld # Default: SERVER_ADDR=:6365\ngo test ./... # Unit tests\ngo test ./tests/contract/... # Contract tests\nmake build\n```\n\n</system-reminder>",
|
||
"title": "go-backend/internal/store/repo/repository.go",
|
||
"metadata": {
|
||
"preview": "\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}",
|
||
"truncated": true,
|
||
"loaded": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/AGENTS.md"
|
||
]
|
||
},
|
||
"time": {
|
||
"start": 1772503865426,
|
||
"end": 1772503865451
|
||
}
|
||
},
|
||
"id": "prt_cb176978c001Ve7DvsOs3u4TC4",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1768032001LccLsv1mXx6pet"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 96193,
|
||
"input": 96093,
|
||
"output": 100,
|
||
"reasoning": 24,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb176988a001Tre7T6gQxU3yTK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1768032001LccLsv1mXx6pet"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503865548,
|
||
"completed": 1772503876771
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 99284,
|
||
"input": 99027,
|
||
"output": 257,
|
||
"reasoning": 177,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb17698cc0016LlULIIZavTrph",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb176c2c9001XwmQeXw2xCfx1b",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17698cc0016LlULIIZavTrph"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Identifying missing SQLite columns**",
|
||
"time": {
|
||
"start": 1772503876298,
|
||
"end": 1772503876691
|
||
},
|
||
"id": "prt_cb176c2ca001Y1OynQDVPrldSs",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17698cc0016LlULIIZavTrph"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_ER7jEkmlWxZHHsmsBsujBzNS",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "prepareSQLiteLegacyColumns",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 2 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 89: \tif err := prepareSQLiteLegacyColumns(db); err != nil {\n Line 256: func prepareSQLiteLegacyColumns(db *gorm.DB) error {",
|
||
"title": "prepareSQLiteLegacyColumns",
|
||
"metadata": {
|
||
"matches": 2,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503876672,
|
||
"end": 1772503876688
|
||
}
|
||
},
|
||
"id": "prt_cb176c2cb001SaxUkDEFQbiiAR",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17698cc0016LlULIIZavTrph"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 99284,
|
||
"input": 99027,
|
||
"output": 257,
|
||
"reasoning": 177,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb176c454001VM1JvZembnRA3C",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17698cc0016LlULIIZavTrph"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503876773,
|
||
"completed": 1772503885773
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 99519,
|
||
"input": 99207,
|
||
"output": 312,
|
||
"reasoning": 233,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb176c4a5001ChdnWMvR2Z056Q",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb176e605001GJV8aRuG19tCdC",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176c4a5001ChdnWMvR2Z056Q"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning SQLite legacy migration update**",
|
||
"time": {
|
||
"start": 1772503885319,
|
||
"end": 1772503885695
|
||
},
|
||
"id": "prt_cb176e607001yoWId71zzAI3bo",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176c4a5001ChdnWMvR2Z056Q"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_eITTjm22NiZjozBVvXdPflfz",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go",
|
||
"offset": 1,
|
||
"limit": 280
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go</path>\n<type>file</type>\n<content>1: package repo\n2: \n3: import (\n4: \t\"database/sql\"\n5: \t\"errors\"\n6: \t\"testing\"\n7: \n8: \tgsqlite \"github.com/glebarez/sqlite\"\n9: \t\"gorm.io/gorm\"\n10: \t\"gorm.io/gorm/logger\"\n11: )\n12: \n13: func TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {\n14: \tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n15: \t\tLogger: logger.Default.LogMode(logger.Silent),\n16: \t})\n17: \tif err != nil {\n18: \t\tt.Fatalf(\"open sqlite: %v\", err)\n19: \t}\n20: \tt.Cleanup(func() {\n21: \t\tsqlDB, _ := db.DB()\n22: \t\tif sqlDB != nil {\n23: \t\t\t_ = sqlDB.Close()\n24: \t\t}\n25: \t})\n26: \n27: \tif err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {\n28: \t\tt.Fatalf(\"create schema_version: %v\", err)\n29: \t}\n30: \tif err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {\n31: \t\tt.Fatalf(\"seed schema_version: %v\", err)\n32: \t}\n33: \n34: \tcalled := 0\n35: \toriginal := ensurePostgresIDDefaultsFn\n36: \tensurePostgresIDDefaultsFn = func(db *gorm.DB) error {\n37: \t\tcalled++\n38: \t\treturn nil\n39: \t}\n40: \tt.Cleanup(func() {\n41: \t\tensurePostgresIDDefaultsFn = original\n42: \t})\n43: \n44: \tif err := migrateSchema(db); err != nil {\n45: \t\tt.Fatalf(\"migrateSchema: %v\", err)\n46: \t}\n47: \tif called != 1 {\n48: \t\tt.Fatalf(\"expected postgres id repair to run once, got %d\", called)\n49: \t}\n50: }\n51: \n52: func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {\n53: \tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n54: \t\tLogger: logger.Default.LogMode(logger.Silent),\n55: \t})\n56: \tif err != nil {\n57: \t\tt.Fatalf(\"open sqlite: %v\", err)\n58: \t}\n59: \tt.Cleanup(func() {\n60: \t\tsqlDB, _ := db.DB()\n61: \t\tif sqlDB != nil {\n62: \t\t\t_ = sqlDB.Close()\n63: \t\t}\n64: \t})\n65: \n66: \tif err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {\n67: \t\tt.Fatalf(\"create schema_version: %v\", err)\n68: \t}\n69: \tif err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, currentSchemaVersion).Error; err != nil {\n70: \t\tt.Fatalf(\"seed schema_version: %v\", err)\n71: \t}\n72: \n73: \twantErr := errors.New(\"repair failed\")\n74: \toriginal := ensurePostgresIDDefaultsFn\n75: \tensurePostgresIDDefaultsFn = func(db *gorm.DB) error {\n76: \t\treturn wantErr\n77: \t}\n78: \tt.Cleanup(func() {\n79: \t\tensurePostgresIDDefaultsFn = original\n80: \t})\n81: \n82: \terr = migrateSchema(db)\n83: \tif !errors.Is(err, wantErr) {\n84: \t\tt.Fatalf(\"expected error %v, got %v\", wantErr, err)\n85: \t}\n86: }\n87: \n88: func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {\n89: \tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n90: \t\tLogger: logger.Default.LogMode(logger.Silent),\n91: \t})\n92: \tif err != nil {\n93: \t\tt.Fatalf(\"open sqlite: %v\", err)\n94: \t}\n95: \tt.Cleanup(func() {\n96: \t\tsqlDB, _ := db.DB()\n97: \t\tif sqlDB != nil {\n98: \t\t\t_ = sqlDB.Close()\n99: \t\t}\n100: \t})\n101: \n102: \tif err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {\n103: \t\tt.Fatalf(\"create schema_version: %v\", err)\n104: \t}\n105: \tif err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {\n106: \t\tt.Fatalf(\"seed schema_version: %v\", err)\n107: \t}\n108: \n109: \toriginalIDRepair := ensurePostgresIDDefaultsFn\n110: \tensurePostgresIDDefaultsFn = func(db *gorm.DB) error {\n111: \t\treturn nil\n112: \t}\n113: \tt.Cleanup(func() {\n114: \t\tensurePostgresIDDefaultsFn = originalIDRepair\n115: \t})\n116: \n117: \tcalled := 0\n118: \toriginalMigrate := migrateViteConfigValueColumnTypeFn\n119: \tmigrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {\n120: \t\tcalled++\n121: \t\treturn nil\n122: \t}\n123: \tt.Cleanup(func() {\n124: \t\tmigrateViteConfigValueColumnTypeFn = originalMigrate\n125: \t})\n126: \n127: \tif err := migrateSchema(db); err != nil {\n128: \t\tt.Fatalf(\"migrateSchema: %v\", err)\n129: \t}\n130: \n131: \tif called != 1 {\n132: \t\tt.Fatalf(\"expected vite_config migration to run once, got %d\", called)\n133: \t}\n134: }\n135: \n136: func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {\n137: \tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n138: \t\tLogger: logger.Default.LogMode(logger.Silent),\n139: \t})\n140: \tif err != nil {\n141: \t\tt.Fatalf(\"open sqlite: %v\", err)\n142: \t}\n143: \tt.Cleanup(func() {\n144: \t\tsqlDB, _ := db.DB()\n145: \t\tif sqlDB != nil {\n146: \t\t\t_ = sqlDB.Close()\n147: \t\t}\n148: \t})\n149: \n150: \tif err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {\n151: \t\tt.Fatalf(\"create schema_version: %v\", err)\n152: \t}\n153: \tif err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {\n154: \t\tt.Fatalf(\"seed schema_version: %v\", err)\n155: \t}\n156: \n157: \toriginalIDRepair := ensurePostgresIDDefaultsFn\n158: \tensurePostgresIDDefaultsFn = func(db *gorm.DB) error {\n159: \t\treturn nil\n160: \t}\n161: \tt.Cleanup(func() {\n162: \t\tensurePostgresIDDefaultsFn = originalIDRepair\n163: \t})\n164: \n165: \twantErr := errors.New(\"vite config migration failed\")\n166: \toriginalMigrate := migrateViteConfigValueColumnTypeFn\n167: \tmigrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {\n168: \t\treturn wantErr\n169: \t}\n170: \tt.Cleanup(func() {\n171: \t\tmigrateViteConfigValueColumnTypeFn = originalMigrate\n172: \t})\n173: \n174: \terr = migrateSchema(db)\n175: \tif !errors.Is(err, wantErr) {\n176: \t\tt.Fatalf(\"expected error %v, got %v\", wantErr, err)\n177: \t}\n178: }\n179: \n180: func TestMigrateSchemaClearsSpeedLimitTunnelBinding(t *testing.T) {\n181: \tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n182: \t\tLogger: logger.Default.LogMode(logger.Silent),\n183: \t})\n184: \tif err != nil {\n185: \t\tt.Fatalf(\"open sqlite: %v\", err)\n186: \t}\n187: \tt.Cleanup(func() {\n188: \t\tsqlDB, _ := db.DB()\n189: \t\tif sqlDB != nil {\n190: \t\t\t_ = sqlDB.Close()\n191: \t\t}\n192: \t})\n193: \n194: \tif err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {\n195: \t\tt.Fatalf(\"create schema_version: %v\", err)\n196: \t}\n197: \tif err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 3).Error; err != nil {\n198: \t\tt.Fatalf(\"seed schema_version: %v\", err)\n199: \t}\n200: \tif err := db.Exec(`\n201: \t\tCREATE TABLE speed_limit (\n202: \t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n203: \t\t\tname VARCHAR(100) NOT NULL,\n204: \t\t\tspeed INTEGER NOT NULL,\n205: \t\t\ttunnel_id INTEGER,\n206: \t\t\ttunnel_name VARCHAR(100),\n207: \t\t\tcreated_time INTEGER NOT NULL,\n208: \t\t\tupdated_time INTEGER,\n209: \t\t\tstatus INTEGER NOT NULL\n210: \t\t)\n211: \t`).Error; err != nil {\n212: \t\tt.Fatalf(\"create speed_limit: %v\", err)\n213: \t}\n214: \tif err := db.Exec(`\n215: \t\tINSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)\n216: \t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n217: \t`, \"legacy-speed-limit\", 100, 101, \"legacy-tunnel\", 1, 1, 1).Error; err != nil {\n218: \t\tt.Fatalf(\"seed speed_limit: %v\", err)\n219: \t}\n220: \n221: \toriginalIDRepair := ensurePostgresIDDefaultsFn\n222: \tensurePostgresIDDefaultsFn = func(db *gorm.DB) error {\n223: \t\treturn nil\n224: \t}\n225: \tt.Cleanup(func() {\n226: \t\tensurePostgresIDDefaultsFn = originalIDRepair\n227: \t})\n228: \n229: \tif err := migrateSchema(db); err != nil {\n230: \t\tt.Fatalf(\"migrateSchema: %v\", err)\n231: \t}\n232: \n233: \tvar tunnelID sql.NullInt64\n234: \tvar tunnelName sql.NullString\n235: \tif err := db.Raw(`SELECT tunnel_id, tunnel_name FROM speed_limit WHERE name = ?`, \"legacy-speed-limit\").Row().Scan(&tunnelID, &tunnelName); err != nil {\n236: \t\tt.Fatalf(\"query speed_limit: %v\", err)\n237: \t}\n238: \tif tunnelID.Valid {\n239: \t\tt.Fatalf(\"expected tunnel_id cleared to NULL, got %d\", tunnelID.Int64)\n240: \t}\n241: \tif tunnelName.Valid {\n242: \t\tt.Fatalf(\"expected tunnel_name cleared to NULL, got %q\", tunnelName.String)\n243: \t}\n244: \n245: \tvar schemaVersion int\n246: \tif err := db.Raw(`SELECT version FROM schema_version LIMIT 1`).Row().Scan(&schemaVersion); err != nil {\n247: \t\tt.Fatalf(\"query schema_version: %v\", err)\n248: \t}\n249: \tif schemaVersion != currentSchemaVersion {\n250: \t\tt.Fatalf(\"expected schema version %d, got %d\", currentSchemaVersion, schemaVersion)\n251: \t}\n252: }\n\n(End of file - total 252 lines)\n</content>",
|
||
"title": "go-backend/internal/store/repo/repository_migrate_test.go",
|
||
"metadata": {
|
||
"preview": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"testing\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/logger\"\n)\n\nfunc TestMigrateSchemaRunsPostgresIDRepairEvenAtCurrentVersion(t *testing.T) {\n\tdb, err := gorm.Open(gsqlite.Open(\":memory:\"), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772503885658,
|
||
"end": 1772503885663
|
||
}
|
||
},
|
||
"id": "prt_cb176e657001QW3xOvUmb2fBp7",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176c4a5001ChdnWMvR2Z056Q"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 99519,
|
||
"input": 99207,
|
||
"output": 312,
|
||
"reasoning": 233,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb176e781001y4tXICSpdw9CnF",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176c4a5001ChdnWMvR2Z056Q"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503885774,
|
||
"completed": 1772503898795
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102652,
|
||
"input": 102024,
|
||
"output": 628,
|
||
"reasoning": 409,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb176e7ce0016LlDbIP5wL7x0r",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"id": "prt_cb1771134001qGEJoGjX8TFjEA",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176e7ce0016LlDbIP5wL7x0r"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Adding missing legacy columns**",
|
||
"time": {
|
||
"start": 1772503896373,
|
||
"end": 1772503898690
|
||
},
|
||
"id": "prt_cb1771135001V2gEqWjfVZuhvA",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176e7ce0016LlDbIP5wL7x0r"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_1cjukqOS53T9l1nJ5QlQkyHo",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@\n \tif m.HasTable(&model.Node{}) {\n-\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n+\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n \t\t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\t\tcontinue\n \t\t\t}\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/store/repo/repository.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/store/repo/repository.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@ -259,9 +259,9 @@\n }\n m := db.Migrator()\n \n if m.HasTable(&model.Node{}) {\n-\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n+\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n \t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\tcontinue\n \t\t}\n \t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"relativePath": "go-backend/internal/store/repo/repository.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@ -259,9 +259,9 @@\n }\n m := db.Migrator()\n \n if m.HasTable(&model.Node{}) {\n-\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n+\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n \t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\tcontinue\n \t\t}\n \t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n",
|
||
"before": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"after": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"additions": 1,
|
||
"deletions": 1
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503898336,
|
||
"end": 1772503898571
|
||
}
|
||
},
|
||
"id": "prt_cb1771179001i9sQB30SqoS8R1",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176e7ce0016LlDbIP5wL7x0r"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102652,
|
||
"input": 102024,
|
||
"output": 628,
|
||
"reasoning": 409,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1771a43001beWWzXjGieWYCG",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176e7ce0016LlDbIP5wL7x0r"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "04cb0a88861735db069d56c0412d6588e581d9df",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go"
|
||
],
|
||
"id": "prt_cb1771aa8001CsnL1ZM1mAOufY",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb176e7ce0016LlDbIP5wL7x0r"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503898797,
|
||
"completed": 1772503905253
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102394,
|
||
"input": 102270,
|
||
"output": 124,
|
||
"reasoning": 33,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1771aad00192u1NUoYcj0K9s",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"id": "prt_cb1773054001Z5Onb0yqLUwD2g",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1771aad00192u1NUoYcj0K9s"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Searching for migration test**",
|
||
"time": {
|
||
"start": 1772503904341,
|
||
"end": 1772503905153
|
||
},
|
||
"id": "prt_cb1773055001SGh1xGxl7NNn65",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1771aad00192u1NUoYcj0K9s"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_GsWjkcTIcwSiU1RC7V5Ek0IA",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "prepareSQLiteLegacyColumns|HasColumn\\(&model.Node\\{}, \"ServerIPV4\"|ExtraIPs",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 10 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 89: \tif err := prepareSQLiteLegacyColumns(db); err != nil {\n Line 256: func prepareSQLiteLegacyColumns(db *gorm.DB) error {\n Line 263: \t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n Line 640: \t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_mutations.go:\n Line 209: \t\tExtraIPs: nullStringFromInterface(extraIPs),\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_control.go:\n Line 184: \tif n.ExtraIPs.Valid {\n Line 185: \t\trec.ExtraIPs = strings.TrimSpace(n.ExtraIPs.String)\n\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go:\n Line 67: \tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n Line 343: \tExtraIPs string `json:\"extraIPs,omitempty\"`\n Line 527: \tExtraIPs string",
|
||
"title": "prepareSQLiteLegacyColumns|HasColumn\\(&model.Node\\{}, \"ServerIPV4\"|ExtraIPs",
|
||
"metadata": {
|
||
"matches": 10,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503904960,
|
||
"end": 1772503904979
|
||
}
|
||
},
|
||
"id": "prt_cb17730700016YyFi62qnNRMSZ",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1771aad00192u1NUoYcj0K9s"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102394,
|
||
"input": 102270,
|
||
"output": 124,
|
||
"reasoning": 33,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17733820019Y17d6XzGUpusB",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1771aad00192u1NUoYcj0K9s"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503905255,
|
||
"completed": 1772503911369
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102879,
|
||
"input": 102784,
|
||
"output": 95,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb17733e7001QEcqBrDpxHVD8T",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"id": "prt_cb177419b001W1xHfsz33iN5rC",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17733e7001QEcqBrDpxHVD8T"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_bFFveN9EFpz6eb3jxjSnPPea",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./internal/store/repo -run \"TestMigrateSchema|TestMigrate\"",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Runs repository migration focused tests"
|
||
},
|
||
"output": "ok \tgo-backend/internal/store/repo\t0.689s\n",
|
||
"title": "Runs repository migration focused tests",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/internal/store/repo\t0.689s\n",
|
||
"exit": 0,
|
||
"description": "Runs repository migration focused tests",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503909389,
|
||
"end": 1772503911309
|
||
}
|
||
},
|
||
"id": "prt_cb177419c001saap5pnGNhyxjX",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17733e7001QEcqBrDpxHVD8T"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 102879,
|
||
"input": 102784,
|
||
"output": 95,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1774b8e0013il1yAQRMJ1ooi",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17733e7001QEcqBrDpxHVD8T"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503911371,
|
||
"completed": 1772503919516
|
||
},
|
||
"parentID": "msg_cb1760739001ZLmOhf2wKkCtNC",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 103194,
|
||
"input": 1017,
|
||
"output": 289,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 101888,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb1774bcb001HDMU3t83OSUuJg",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"id": "prt_cb1775b640017ILRYM4DFC4g6b",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1774bcb001HDMU3t83OSUuJg"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "这个报错我确认到了,根因是 **SQLite 旧库升级路径漏加了 `node.extra_ips` 列**。\n\n你这个场景不是新库初始化(新库会按模型建表),而是旧库启动时走 `prepareSQLiteLegacyColumns` 补列逻辑;之前这里只补了 `ServerIPV4/ServerIPV6/...`,没补 `ExtraIPs`,所以写入时报:\n\n- `SQL logic error: no such column: extra_ips (1)`\n\n我已经修复了:\n\n- 在 `go-backend/internal/store/repo/repository.go:263` 的 SQLite 兼容补列列表里加入了 `ExtraIPs`。\n- 已执行并通过迁移相关测试:`go test ./internal/store/repo -run \"TestMigrateSchema|TestMigrate\"`。\n\n你现在要做的只有一步:\n\n- 重启后端服务(让启动时迁移逻辑执行一次),比如重新启动 `paneld` / 容器。\n\n重启后再去“节点 -> 额外IP”保存,应该就不会再报这个错了。 \n如果你愿意,我也可以再帮你快速加一个回归测试,专门覆盖“旧 SQLite 库缺 `extra_ips` 自动补列”这个场景,避免以后再回归。",
|
||
"time": {
|
||
"start": 1772503919417,
|
||
"end": 1772503919417
|
||
},
|
||
"id": "prt_cb1775b65001x0JLGb0O0ZfWG2",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1774bcb001HDMU3t83OSUuJg"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "69eaca81f7bd0512662566c158e2d96e1680b31e",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 103194,
|
||
"input": 1017,
|
||
"output": 289,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 101888,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1776b3a001xbsGOGDsBVi0Pw",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1774bcb001HDMU3t83OSUuJg"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772503974577
|
||
},
|
||
"summary": {
|
||
"diffs": [
|
||
{
|
||
"file": "go-backend/internal/store/repo/repository.go",
|
||
"before": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"after": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"ExtraIPs\", \"TCPListenAddr\", \"UDPListenAddr\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"additions": 1,
|
||
"deletions": 1,
|
||
"status": "modified"
|
||
},
|
||
{
|
||
"file": "go-backend/tests/contract/migration_contract_test.go",
|
||
"before": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tif !tunnelColumns[\"inx\"] {\n\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"after": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"additions": 5,
|
||
"deletions": 3,
|
||
"status": "modified"
|
||
}
|
||
]
|
||
},
|
||
"agent": "build",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "全量排查一下还有没有类似的bug",
|
||
"id": "prt_cb17842b1002MqfbQpVT3UwJrd",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842b10010HDxnEyBULStV5"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503974591,
|
||
"completed": 1772503987566
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 103727,
|
||
"input": 103210,
|
||
"output": 517,
|
||
"reasoning": 333,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb17842bf001kEFTHMWBc5BvPU",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"id": "prt_cb178731e001lQ4OYpZYtlD1K0",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842bf001kEFTHMWBc5BvPU"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Auditing legacy column migration**",
|
||
"time": {
|
||
"start": 1772503986974,
|
||
"end": 1772503987493
|
||
},
|
||
"id": "prt_cb178731e002jNG6O4lqjVfq5d",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842bf001kEFTHMWBc5BvPU"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_cULak6v3s43tytkrIqPuMmXd",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go",
|
||
"offset": 1,
|
||
"limit": 220
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go</path>\n<type>file</type>\n<content>1: // Package model defines GORM model structs for all database tables,\n2: // providing a single source of truth for the schema that works\n3: // transparently with both SQLite and PostgreSQL.\n4: package model\n5: \n6: import \"database/sql\"\n7: \n8: // ─── Core Business Tables ────────────────────────────────────────────\n9: \n10: // User maps to the \"user\" table. PostgreSQL treats \"user\" as a reserved\n11: // word, so TableName() is required for correct quoting.\n12: type User struct {\n13: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n14: \tUser string `gorm:\"column:user;type:varchar(100);not null\"`\n15: \tPwd string `gorm:\"type:varchar(100);not null\"`\n16: \tRoleID int `gorm:\"column:role_id;not null\"`\n17: \tExpTime int64 `gorm:\"column:exp_time;not null\"`\n18: \tFlow int64 `gorm:\"not null\"`\n19: \tInFlow int64 `gorm:\"column:in_flow;not null;default:0\"`\n20: \tOutFlow int64 `gorm:\"column:out_flow;not null;default:0\"`\n21: \tFlowResetTime int64 `gorm:\"column:flow_reset_time;not null\"`\n22: \tNum int `gorm:\"not null\"`\n23: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n24: \tUpdatedTime sql.NullInt64 `gorm:\"column:updated_time\"`\n25: \tStatus int `gorm:\"not null\"`\n26: }\n27: \n28: func (User) TableName() string { return \"user\" }\n29: \n30: // Forward maps to the \"forward\" table.\n31: type Forward struct {\n32: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n33: \tUserID int64 `gorm:\"column:user_id;not null\"`\n34: \tUserName string `gorm:\"column:user_name;type:varchar(100);not null\"`\n35: \tName string `gorm:\"type:varchar(100);not null\"`\n36: \tTunnelID int64 `gorm:\"column:tunnel_id;not null\"`\n37: \tRemoteAddr string `gorm:\"column:remote_addr;type:text;not null\"`\n38: \tStrategy string `gorm:\"type:varchar(100);not null;default:'fifo'\"`\n39: \tInFlow int64 `gorm:\"not null;default:0\"`\n40: \tOutFlow int64 `gorm:\"column:out_flow;not null;default:0\"`\n41: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n42: \tUpdatedTime int64 `gorm:\"column:updated_time;not null\"`\n43: \tStatus int `gorm:\"not null\"`\n44: \tInx int `gorm:\"not null;default:0\"`\n45: \tSpeedID sql.NullInt64 `gorm:\"column:speed_id\"`\n46: }\n47: \n48: func (Forward) TableName() string { return \"forward\" }\n49: \n50: type ForwardPort struct {\n51: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n52: \tForwardID int64 `gorm:\"column:forward_id;not null\"`\n53: \tNodeID int64 `gorm:\"column:node_id;not null\"`\n54: \tPort int `gorm:\"not null\"`\n55: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n56: }\n57: \n58: func (ForwardPort) TableName() string { return \"forward_port\" }\n59: \n60: type Node struct {\n61: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n62: \tName string `gorm:\"type:varchar(100);not null\"`\n63: \tSecret string `gorm:\"type:varchar(100);not null\"`\n64: \tServerIP string `gorm:\"column:server_ip;type:varchar(100);not null\"`\n65: \tServerIPV4 sql.NullString `gorm:\"column:server_ip_v4;type:varchar(100)\"`\n66: \tServerIPV6 sql.NullString `gorm:\"column:server_ip_v6;type:varchar(100)\"`\n67: \tExtraIPs sql.NullString `gorm:\"column:extra_ips;type:text\"`\n68: \tPort string `gorm:\"type:text;not null\"`\n69: \tInterfaceName sql.NullString `gorm:\"column:interface_name;type:varchar(200)\"`\n70: \tVersion sql.NullString `gorm:\"type:varchar(100)\"`\n71: \tHTTP int `gorm:\"column:http;not null;default:0\"`\n72: \tTLS int `gorm:\"column:tls;not null;default:0\"`\n73: \tSocks int `gorm:\"not null;default:0\"`\n74: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n75: \tUpdatedTime sql.NullInt64 `gorm:\"column:updated_time\"`\n76: \tStatus int `gorm:\"not null\"`\n77: \tTCPListenAddr string `gorm:\"column:tcp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n78: \tUDPListenAddr string `gorm:\"column:udp_listen_addr;type:varchar(100);not null;default:'[::]'\"`\n79: \tInx int `gorm:\"not null;default:0\"`\n80: \tIsRemote int `gorm:\"column:is_remote;default:0\"`\n81: \tRemoteURL sql.NullString `gorm:\"column:remote_url;type:text\"`\n82: \tRemoteToken sql.NullString `gorm:\"column:remote_token;type:text\"`\n83: \tRemoteConfig sql.NullString `gorm:\"column:remote_config;type:text\"`\n84: }\n85: \n86: func (Node) TableName() string { return \"node\" }\n87: \n88: type SpeedLimit struct {\n89: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n90: \tName string `gorm:\"type:varchar(100);not null\"`\n91: \tSpeed int `gorm:\"not null\"`\n92: \tTunnelID sql.NullInt64 `gorm:\"column:tunnel_id\"`\n93: \tTunnelName sql.NullString `gorm:\"column:tunnel_name;type:varchar(100)\"`\n94: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n95: \tUpdatedTime sql.NullInt64 `gorm:\"column:updated_time\"`\n96: \tStatus int `gorm:\"not null\"`\n97: }\n98: \n99: func (SpeedLimit) TableName() string { return \"speed_limit\" }\n100: \n101: type StatisticsFlow struct {\n102: \tID int64 `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n103: \tUserID int64 `gorm:\"column:user_id;not null\" json:\"userId\"`\n104: \tFlow int64 `gorm:\"not null\" json:\"flow\"`\n105: \tTotalFlow int64 `gorm:\"column:total_flow;not null\" json:\"totalFlow\"`\n106: \tTime string `gorm:\"type:varchar(100);not null\" json:\"time\"`\n107: \tCreatedTime int64 `gorm:\"column:created_time;not null\" json:\"-\"`\n108: }\n109: \n110: func (StatisticsFlow) TableName() string { return \"statistics_flow\" }\n111: \n112: type Tunnel struct {\n113: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n114: \tName string `gorm:\"type:varchar(100);not null\"`\n115: \tTrafficRatio float64 `gorm:\"column:traffic_ratio;not null;default:1.0\"`\n116: \tType int `gorm:\"not null\"`\n117: \tProtocol string `gorm:\"type:varchar(10);not null;default:'tls'\"`\n118: \tFlow int64 `gorm:\"not null\"`\n119: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n120: \tUpdatedTime int64 `gorm:\"column:updated_time;not null\"`\n121: \tStatus int `gorm:\"not null\"`\n122: \tInIP sql.NullString `gorm:\"column:in_ip;type:text\"`\n123: \tInx int `gorm:\"not null;default:0\"`\n124: \tIPPreference string `gorm:\"column:ip_preference;type:varchar(10);not null;default:''\"`\n125: }\n126: \n127: func (Tunnel) TableName() string { return \"tunnel\" }\n128: \n129: type ChainTunnel struct {\n130: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n131: \tTunnelID int64 `gorm:\"column:tunnel_id;not null\"`\n132: \tChainType string `gorm:\"column:chain_type;type:varchar(10);not null\"`\n133: \tNodeID int64 `gorm:\"column:node_id;not null\"`\n134: \tPort sql.NullInt64 `gorm:\"column:port\"`\n135: \tStrategy sql.NullString `gorm:\"type:varchar(10)\"`\n136: \tInx sql.NullInt64 `gorm:\"column:inx\"`\n137: \tProtocol sql.NullString `gorm:\"type:varchar(10)\"`\n138: \tConnectIP sql.NullString `gorm:\"column:connect_ip;type:varchar(45)\"`\n139: }\n140: \n141: func (ChainTunnel) TableName() string { return \"chain_tunnel\" }\n142: \n143: type UserTunnel struct {\n144: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n145: \tUserID int64 `gorm:\"column:user_id;not null;uniqueIndex:idx_user_tunnel_unique\"`\n146: \tTunnelID int64 `gorm:\"column:tunnel_id;not null;uniqueIndex:idx_user_tunnel_unique\"`\n147: \tSpeedID sql.NullInt64 `gorm:\"column:speed_id\"`\n148: \tNum int `gorm:\"not null\"`\n149: \tFlow int64 `gorm:\"not null\"`\n150: \tInFlow int64 `gorm:\"column:in_flow;not null;default:0\"`\n151: \tOutFlow int64 `gorm:\"column:out_flow;not null;default:0\"`\n152: \tFlowResetTime int64 `gorm:\"column:flow_reset_time;not null\"`\n153: \tExpTime int64 `gorm:\"column:exp_time;not null\"`\n154: \tStatus int `gorm:\"not null\"`\n155: }\n156: \n157: func (UserTunnel) TableName() string { return \"user_tunnel\" }\n158: \n159: type TunnelGroup struct {\n160: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n161: \tName string `gorm:\"type:varchar(100);not null;uniqueIndex:idx_tunnel_group_name\"`\n162: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n163: \tUpdatedTime int64 `gorm:\"column:updated_time;not null\"`\n164: \tStatus int `gorm:\"not null\"`\n165: }\n166: \n167: func (TunnelGroup) TableName() string { return \"tunnel_group\" }\n168: \n169: type UserGroup struct {\n170: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n171: \tName string `gorm:\"type:varchar(100);not null;uniqueIndex:idx_user_group_name\"`\n172: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n173: \tUpdatedTime int64 `gorm:\"column:updated_time;not null\"`\n174: \tStatus int `gorm:\"not null\"`\n175: }\n176: \n177: func (UserGroup) TableName() string { return \"user_group\" }\n178: \n179: type TunnelGroupTunnel struct {\n180: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n181: \tTunnelGroupID int64 `gorm:\"column:tunnel_group_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique\"`\n182: \tTunnelID int64 `gorm:\"column:tunnel_id;not null;uniqueIndex:idx_tunnel_group_tunnel_unique\"`\n183: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n184: }\n185: \n186: func (TunnelGroupTunnel) TableName() string { return \"tunnel_group_tunnel\" }\n187: \n188: type UserGroupUser struct {\n189: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n190: \tUserGroupID int64 `gorm:\"column:user_group_id;not null;uniqueIndex:idx_user_group_user_unique\"`\n191: \tUserID int64 `gorm:\"column:user_id;not null;uniqueIndex:idx_user_group_user_unique\"`\n192: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n193: }\n194: \n195: func (UserGroupUser) TableName() string { return \"user_group_user\" }\n196: \n197: type GroupPermission struct {\n198: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n199: \tUserGroupID int64 `gorm:\"column:user_group_id;not null;uniqueIndex:idx_group_permission_unique\"`\n200: \tTunnelGroupID int64 `gorm:\"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_unique\"`\n201: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n202: }\n203: \n204: func (GroupPermission) TableName() string { return \"group_permission\" }\n205: \n206: type GroupPermissionGrant struct {\n207: \tID int64 `gorm:\"primaryKey;autoIncrement\"`\n208: \tUserGroupID int64 `gorm:\"column:user_group_id;not null;uniqueIndex:idx_group_permission_grant_unique\"`\n209: \tTunnelGroupID int64 `gorm:\"column:tunnel_group_id;not null;uniqueIndex:idx_group_permission_grant_unique\"`\n210: \tUserTunnelID int64 `gorm:\"column:user_tunnel_id;not null;uniqueIndex:idx_group_permission_grant_unique\"`\n211: \tCreatedByGroup int `gorm:\"column:created_by_group;not null;default:0\"`\n212: \tCreatedTime int64 `gorm:\"column:created_time;not null\"`\n213: }\n214: \n215: func (GroupPermissionGrant) TableName() string { return \"group_permission_grant\" }\n216: \n217: type ViteConfig struct {\n218: \tID int64 `gorm:\"primaryKey;autoIncrement\" json:\"id\"`\n219: \tName string `gorm:\"type:varchar(200);not null;uniqueIndex\" json:\"name\"`\n220: \tValue string `gorm:\"type:text;not null\" json:\"value\"`\n\n(Showing lines 1-220 of 601. Use offset=221 to continue.)\n</content>",
|
||
"title": "go-backend/internal/store/model/model.go",
|
||
"metadata": {
|
||
"preview": "// Package model defines GORM model structs for all database tables,\n// providing a single source of truth for the schema that works\n// transparently with both SQLite and PostgreSQL.\npackage model\n\nimport \"database/sql\"\n\n// ─── Core Business Tables ────────────────────────────────────────────\n\n// User maps to the \"user\" table. PostgreSQL treats \"user\" as a reserved\n// word, so TableName() is required for correct quoting.\ntype User struct {\n\tID int64 `gorm:\"primaryKey;autoIncrement\"`\n\tUser string `gorm:\"column:user;type:varchar(100);not null\"`\n\tPwd string `gorm:\"type:varchar(100);not null\"`\n\tRoleID int `gorm:\"column:role_id;not null\"`\n\tExpTime int64 `gorm:\"column:exp_time;not null\"`\n\tFlow int64 `gorm:\"not null\"`\n\tInFlow int64 `gorm:\"column:in_flow;not null;default:0\"`\n\tOutFlow int64 `gorm:\"column:out_flow;not null;default:0\"`",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772503987457,
|
||
"end": 1772503987469
|
||
}
|
||
},
|
||
"id": "prt_cb17874f60010WopMUN7VlPGSd",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842bf001kEFTHMWBc5BvPU"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_ZPvsz2Rgr5spZdJBW9Qk3Z4A",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "hasNode|hasTunnel|HasTable\\(&model.Node\\{}\\)|HasTable\\(&model.Tunnel\\{}\\)",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo",
|
||
"include": "*.go"
|
||
},
|
||
"output": "Found 6 matches\n/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go:\n Line 191: \thasNode := m.HasTable(&model.Node{})\n Line 192: \thasTunnel := m.HasTable(&model.Tunnel{})\n Line 195: \t\tif hasNode {\n Line 200: \t\tif hasTunnel {\n Line 262: \tif m.HasTable(&model.Node{}) {\n Line 273: \tif m.HasTable(&model.Tunnel{}) {",
|
||
"title": "hasNode|hasTunnel|HasTable\\(&model.Node\\{}\\)|HasTable\\(&model.Tunnel\\{}\\)",
|
||
"metadata": {
|
||
"matches": 6,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772503987467,
|
||
"end": 1772503987484
|
||
}
|
||
},
|
||
"id": "prt_cb1787506001qbf0vGHEVk6GRg",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842bf001kEFTHMWBc5BvPU"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 103727,
|
||
"input": 103210,
|
||
"output": 517,
|
||
"reasoning": 333,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1787527001enMG0AAamtEsh7",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17842bf001kEFTHMWBc5BvPU"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772503987569,
|
||
"completed": 1772504001685
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 107596,
|
||
"input": 107033,
|
||
"output": 563,
|
||
"reasoning": 389,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1787571001oNKB8TzRPOFS5h",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"id": "prt_cb178a87a001Dv23M2QcIQkjRx",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1787571001oNKB8TzRPOFS5h"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Assessing migration gaps for legacy columns**",
|
||
"time": {
|
||
"start": 1772504000635,
|
||
"end": 1772504001609
|
||
},
|
||
"id": "prt_cb178a87b001BquJ1ujIzNmlrA",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1787571001oNKB8TzRPOFS5h"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_67xhhsOkLqBAJbh47cyyCfns",
|
||
"tool": "grep",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"pattern": "CREATE TABLE node|CREATE TABLE tunnel|CREATE TABLE forward_port|CREATE TABLE chain_tunnel",
|
||
"path": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"include": "*.go"
|
||
},
|
||
"output": "No files found",
|
||
"title": "CREATE TABLE node|CREATE TABLE tunnel|CREATE TABLE forward_port|CREATE TABLE chain_tunnel",
|
||
"metadata": {
|
||
"matches": 0,
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504001593,
|
||
"end": 1772504001615
|
||
}
|
||
},
|
||
"id": "prt_cb178ac34001jIlV2TzjI0Dp9B",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1787571001oNKB8TzRPOFS5h"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_NIwAC36MxMrPbQyTIfaVpurs",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go",
|
||
"offset": 600,
|
||
"limit": 220
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go</path>\n<type>file</type>\n<content>600: \tif err != nil {\n601: \t\tt.Fatalf(\"open sqlite: %v\", err)\n602: \t}\n603: \tt.Cleanup(func() {\n604: \t\t_ = r.Close()\n605: \t})\n606: \n607: \th := handler.New(r, jwtSecret)\n608: \treturn httpserver.NewRouter(h, jwtSecret), r\n609: }\n610: \n611: func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n612: \tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n613: \tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n614: \tif err != nil {\n615: \t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n616: \t}\n617: \n618: \tt.Cleanup(func() {\n619: \t\t_ = legacyDB.Close()\n620: \t})\n621: \n622: \tif _, err := legacyDB.Exec(`\n623: \t\tCREATE TABLE IF NOT EXISTS node (\n624: \t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n625: \t\t\tname VARCHAR(100) NOT NULL,\n626: \t\t\tsecret VARCHAR(100) NOT NULL,\n627: \t\t\tserver_ip VARCHAR(100) NOT NULL,\n628: \t\t\tport TEXT NOT NULL,\n629: \t\t\tinterface_name VARCHAR(200),\n630: \t\t\tversion VARCHAR(100),\n631: \t\t\thttp INTEGER NOT NULL DEFAULT 0,\n632: \t\t\ttls INTEGER NOT NULL DEFAULT 0,\n633: \t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n634: \t\t\tcreated_time INTEGER NOT NULL,\n635: \t\t\tupdated_time INTEGER,\n636: \t\t\tstatus INTEGER NOT NULL,\n637: \t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n638: \t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n639: \t\t)\n640: \t`); err != nil {\n641: \t\tt.Fatalf(\"create legacy node table: %v\", err)\n642: \t}\n643: \n644: \tif _, err := legacyDB.Exec(`\n645: \t\tCREATE TABLE IF NOT EXISTS tunnel (\n646: \t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n647: \t\t\tname VARCHAR(100) NOT NULL,\n648: \t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n649: \t\t\ttype INTEGER NOT NULL,\n650: \t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n651: \t\t\tflow INTEGER NOT NULL,\n652: \t\t\tcreated_time INTEGER NOT NULL,\n653: \t\t\tupdated_time INTEGER NOT NULL,\n654: \t\t\tstatus INTEGER NOT NULL,\n655: \t\t\tin_ip TEXT\n656: \t\t)\n657: \t`); err != nil {\n658: \t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n659: \t}\n660: \n661: \tnow := time.Now().UnixMilli()\n662: \tif _, err := legacyDB.Exec(`\n663: \t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n664: \t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n665: \t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n666: \t\tt.Fatalf(\"seed legacy node row: %v\", err)\n667: \t}\n668: \n669: \tr, err := repo.Open(dbPath)\n670: \tif err != nil {\n671: \t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n672: \t}\n673: \tt.Cleanup(func() {\n674: \t\t_ = r.Close()\n675: \t})\n676: \n677: \tnodes, err := r.ListNodes()\n678: \tif err != nil {\n679: \t\tt.Fatalf(\"list nodes after migration: %v\", err)\n680: \t}\n681: \tif len(nodes) != 1 {\n682: \t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n683: \t}\n684: \n685: \tcolumns := readTableColumns(t, r.DB(), \"node\")\n686: \n687: \tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n688: \t\tif !columns[required] {\n689: \t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n690: \t\t}\n691: \t}\n692: \n693: \ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n694: \tif !tunnelColumns[\"inx\"] {\n695: \t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n696: \t}\n697: }\n698: \n699: func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n700: \tt.Helper()\n701: \n702: \tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n703: \tif err != nil {\n704: \t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n705: \t}\n706: \n707: \tcolumns := map[string]bool{}\n708: \tfor _, col := range columnTypes {\n709: \t\tname := strings.TrimSpace(col.Name())\n710: \t\tif name == \"\" {\n711: \t\t\tcontinue\n712: \t\t}\n713: \t\tcolumns[name] = true\n714: \t}\n715: \n716: \treturn columns\n717: }\n\n(End of file - total 717 lines)\n</content>",
|
||
"title": "go-backend/tests/contract/migration_contract_test.go",
|
||
"metadata": {
|
||
"preview": "\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()",
|
||
"truncated": false,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772504001598,
|
||
"end": 1772504001612
|
||
}
|
||
},
|
||
"id": "prt_cb178ac3c001PkxfZDMhLGbqAO",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1787571001oNKB8TzRPOFS5h"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 107596,
|
||
"input": 107033,
|
||
"output": 563,
|
||
"reasoning": 389,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb178ac500018EFr2dF1LKLQbN",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1787571001oNKB8TzRPOFS5h"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504001687,
|
||
"completed": 1772504013054
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 108873,
|
||
"input": 108532,
|
||
"output": 341,
|
||
"reasoning": 265,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb178ac970017dveDqKzQpabbj",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"id": "prt_cb178d74c001WxOEybxTpOQecU",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178ac970017dveDqKzQpabbj"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Assessing SQLite migration skips**",
|
||
"time": {
|
||
"start": 1772504012621,
|
||
"end": 1772504012965
|
||
},
|
||
"id": "prt_cb178d74d001yu5wIsXc3w6wt9",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178ac970017dveDqKzQpabbj"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_wWJY4khVH5AcUaqUEZ6PgzpG",
|
||
"tool": "read",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"offset": 1,
|
||
"limit": 150
|
||
},
|
||
"output": "<path>/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go</path>\n<type>file</type>\n<content>1: package repo\n2: \n3: import (\n4: \t\"database/sql\"\n5: \t\"errors\"\n6: \t\"fmt\"\n7: \t\"log\"\n8: \t\"os\"\n9: \t\"path/filepath\"\n10: \t\"sort\"\n11: \t\"strings\"\n12: \t\"time\"\n13: \n14: \tgsqlite \"github.com/glebarez/sqlite\"\n15: \t\"gorm.io/driver/postgres\"\n16: \t\"gorm.io/gorm\"\n17: \t\"gorm.io/gorm/clause\"\n18: \t\"gorm.io/gorm/logger\"\n19: \n20: \t\"go-backend/internal/store/model\"\n21: )\n22: \n23: // ─── Type aliases for backward compatibility ─────────────────────────\n24: // Handlers still reference repo.User, repo.BackupData, etc.\n25: \n26: type User = model.User\n27: type ViteConfig = model.ViteConfig\n28: type Announcement = model.Announcement\n29: type UserTunnelDetail = model.UserTunnelDetail\n30: type UserForwardDetail = model.UserForwardDetail\n31: type StatisticsFlow = model.StatisticsFlow\n32: type Node = model.Node\n33: type PeerShare = model.PeerShare\n34: type PeerShareRuntime = model.PeerShareRuntime\n35: type FederationTunnelBinding = model.FederationTunnelBinding\n36: type BackupData = model.BackupData\n37: type UserBackup = model.UserBackup\n38: type NodeBackup = model.NodeBackup\n39: type TunnelBackup = model.TunnelBackup\n40: type ChainTunnelBackup = model.ChainTunnelBackup\n41: type ForwardBackup = model.ForwardBackup\n42: type ForwardPortBackup = model.ForwardPortBackup\n43: type UserTunnelBackup = model.UserTunnelBackup\n44: type SpeedLimitBackup = model.SpeedLimitBackup\n45: type TunnelGroupBackup = model.TunnelGroupBackup\n46: type UserGroupBackup = model.UserGroupBackup\n47: type PermissionBackup = model.PermissionBackup\n48: type PermissionGrantBackup = model.PermissionGrantBackup\n49: type ImportResult = model.ImportResult\n50: \n51: // ─── Repository ──────────────────────────────────────────────────────\n52: \n53: type Repository struct {\n54: \tdb *gorm.DB\n55: }\n56: \n57: func (r *Repository) DB() *gorm.DB {\n58: \tif r == nil {\n59: \t\treturn nil\n60: \t}\n61: \treturn r.db\n62: }\n63: \n64: // ─── Open / Close ────────────────────────────────────────────────────\n65: \n66: func Open(path string) (*Repository, error) {\n67: \tif err := ensureParentDir(path); err != nil {\n68: \t\treturn nil, err\n69: \t}\n70: \n71: \tdsn := \"file:\" + path +\n72: \t\t\"?_pragma=busy_timeout(5000)\" +\n73: \t\t\"&_pragma=journal_mode(WAL)\" +\n74: \t\t\"&_pragma=synchronous(NORMAL)\"\n75: \n76: \tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n77: \t\tLogger: logger.Default.LogMode(logger.Silent),\n78: \t})\n79: \tif err != nil {\n80: \t\treturn nil, err\n81: \t}\n82: \n83: \tsqlDB, err := db.DB()\n84: \tif err != nil {\n85: \t\treturn nil, err\n86: \t}\n87: \tsqlDB.SetMaxOpenConns(1)\n88: \n89: \tif err := prepareSQLiteLegacyColumns(db); err != nil {\n90: \t\t_ = sqlDB.Close()\n91: \t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n92: \t}\n93: \n94: \tif err := autoMigrateAll(db); err != nil {\n95: \t\t_ = sqlDB.Close()\n96: \t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n97: \t}\n98: \n99: \tseedData(db)\n100: \n101: \tif err := migrateSchema(db); err != nil {\n102: \t\t_ = sqlDB.Close()\n103: \t\treturn nil, err\n104: \t}\n105: \n106: \treturn &Repository{db: db}, nil\n107: }\n108: \n109: func OpenPostgres(dsn string) (*Repository, error) {\n110: \tif strings.TrimSpace(dsn) == \"\" {\n111: \t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n112: \t}\n113: \n114: \tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n115: \t\tLogger: logger.Default.LogMode(logger.Silent),\n116: \t})\n117: \tif err != nil {\n118: \t\treturn nil, err\n119: \t}\n120: \n121: \tsqlDB, err := db.DB()\n122: \tif err != nil {\n123: \t\treturn nil, err\n124: \t}\n125: \tif err := sqlDB.Ping(); err != nil {\n126: \t\t_ = sqlDB.Close()\n127: \t\treturn nil, err\n128: \t}\n129: \n130: \tif err := preparePostgresLegacySchema(db); err != nil {\n131: \t\t_ = sqlDB.Close()\n132: \t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n133: \t}\n134: \n135: \tif err := autoMigrateAll(db); err != nil {\n136: \t\t_ = sqlDB.Close()\n137: \t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n138: \t}\n139: \n140: \tseedData(db)\n141: \n142: \tif err := migrateSchema(db); err != nil {\n143: \t\t_ = sqlDB.Close()\n144: \t\treturn nil, err\n145: \t}\n146: \n147: \treturn &Repository{db: db}, nil\n148: }\n149: \n150: func (r *Repository) Close() error {\n\n(Showing lines 1-150 of 2846. Use offset=151 to continue.)\n</content>",
|
||
"title": "go-backend/internal/store/repo/repository.go",
|
||
"metadata": {
|
||
"preview": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"",
|
||
"truncated": true,
|
||
"loaded": []
|
||
},
|
||
"time": {
|
||
"start": 1772504012889,
|
||
"end": 1772504012898
|
||
}
|
||
},
|
||
"id": "prt_cb178d7a8001fHftKg6RDzhjAG",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178ac970017dveDqKzQpabbj"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 108873,
|
||
"input": 108532,
|
||
"output": 341,
|
||
"reasoning": 265,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb178d8a70014ImwgXeQY9L9lK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178ac970017dveDqKzQpabbj"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504013056,
|
||
"completed": 1772504049972
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 110828,
|
||
"input": 110027,
|
||
"output": 801,
|
||
"reasoning": 499,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb178d900001U2AgGJjVMc3nmU",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"id": "prt_cb1795f0e001TTXAgG0k44OACm",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178d900001U2AgGJjVMc3nmU"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning legacy migration tests**",
|
||
"time": {
|
||
"start": 1772504047374,
|
||
"end": 1772504049766
|
||
},
|
||
"id": "prt_cb1795f0e0024EXVeXi2xPC3fZ",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178d900001U2AgGJjVMc3nmU"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_H3WqkiE4HG8vEmrNt2ncCuLy",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@\n-\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n+\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n \t\tif !columns[required] {\n \t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n \t\t}\n \t}\n \n \ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n-\tif !tunnelColumns[\"inx\"] {\n-\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n \t}\n }\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/tests/contract/migration_contract_test.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/tests/contract/migration_contract_test.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@ -683,17 +683,19 @@\n \t}\n \n \tcolumns := readTableColumns(t, r.DB(), \"node\")\n \n-\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n+\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n \t\tif !columns[required] {\n \t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n \t\t}\n \t}\n \n \ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n-\tif !tunnelColumns[\"inx\"] {\n-\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n \t}\n }\n \n func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go",
|
||
"relativePath": "go-backend/tests/contract/migration_contract_test.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@ -683,17 +683,19 @@\n \t}\n \n \tcolumns := readTableColumns(t, r.DB(), \"node\")\n \n-\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n+\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n \t\tif !columns[required] {\n \t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n \t\t}\n \t}\n \n \ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n-\tif !tunnelColumns[\"inx\"] {\n-\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n \t}\n }\n \n func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n",
|
||
"before": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tif !tunnelColumns[\"inx\"] {\n\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", \"inx\")\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"after": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"additions": 5,
|
||
"deletions": 3
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504049656,
|
||
"end": 1772504049882
|
||
}
|
||
},
|
||
"id": "prt_cb1795f5c0012twAcoJvurt2Xj",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178d900001U2AgGJjVMc3nmU"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "20054b1c6fbac40ef2431da12f04f23c252c7c01",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 110828,
|
||
"input": 110027,
|
||
"output": 801,
|
||
"reasoning": 499,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17968de001n6MF53NY0qXcYR",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178d900001U2AgGJjVMc3nmU"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "73e6b4ecabd0d2357b6fff64574677c558b8442c",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go"
|
||
],
|
||
"id": "prt_cb1796932001bdMaAvXRNxO8Ew",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb178d900001U2AgGJjVMc3nmU"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504049974,
|
||
"completed": 1772504063056
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 110777,
|
||
"input": 110357,
|
||
"output": 420,
|
||
"reasoning": 324,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1796936001ftmq74Ay2n7RSp",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "20054b1c6fbac40ef2431da12f04f23c252c7c01",
|
||
"id": "prt_cb179907500106JS2uof7hEVUC",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1796936001ftmq74Ay2n7RSp"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Verifying legacy migration coverage**",
|
||
"time": {
|
||
"start": 1772504060022,
|
||
"end": 1772504060651
|
||
},
|
||
"id": "prt_cb1799076001NYl0isbDV5Yf37",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1796936001ftmq74Ay2n7RSp"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_EVbzk1Fg8q5DyYLoBlhxRhL9",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./tests/contract -run TestOpenMigratesLegacyNodeDualStackColumns",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Runs legacy SQLite migration contract test"
|
||
},
|
||
"output": "ok \tgo-backend/tests/contract\t0.671s\n",
|
||
"title": "Runs legacy SQLite migration contract test",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/tests/contract\t0.671s\n",
|
||
"exit": 0,
|
||
"description": "Runs legacy SQLite migration contract test",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504060505,
|
||
"end": 1772504062998
|
||
}
|
||
},
|
||
"id": "prt_cb179908f0015VhwQCnlVPaejq",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1796936001ftmq74Ay2n7RSp"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "20054b1c6fbac40ef2431da12f04f23c252c7c01",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 110777,
|
||
"input": 110357,
|
||
"output": 420,
|
||
"reasoning": 324,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb1799c17001bReEFVClC5pP8c",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1796936001ftmq74Ay2n7RSp"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504063059,
|
||
"completed": 1772504086184
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111786,
|
||
"input": 18956,
|
||
"output": 1310,
|
||
"reasoning": 1065,
|
||
"cache": {
|
||
"read": 91520,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb1799c53001MVO7AWyDyJ0hMf",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "20054b1c6fbac40ef2431da12f04f23c252c7c01",
|
||
"id": "prt_cb179ec3c0012iYnVpvoouF82N",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1799c53001MVO7AWyDyJ0hMf"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Extending legacy migration coverage**",
|
||
"time": {
|
||
"start": 1772504083516,
|
||
"end": 1772504086054
|
||
},
|
||
"id": "prt_cb179ec3c002tuckK9HN6n6GHR",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1799c53001MVO7AWyDyJ0hMf"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_KM3KbRYfXvKtjwgzkBlhFUrD",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@\n-\tif m.HasTable(&model.Node{}) {\n-\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n+\tif m.HasTable(&model.Node{}) {\n+\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"ExtraIPs\", \"TCPListenAddr\", \"UDPListenAddr\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n \t\t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\t\tcontinue\n \t\t\t}\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/internal/store/repo/repository.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/internal/store/repo/repository.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@ -259,9 +259,9 @@\n }\n m := db.Migrator()\n \n if m.HasTable(&model.Node{}) {\n-\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n+\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"ExtraIPs\", \"TCPListenAddr\", \"UDPListenAddr\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n \t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\tcontinue\n \t\t}\n \t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go",
|
||
"relativePath": "go-backend/internal/store/repo/repository.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go\n@@ -259,9 +259,9 @@\n }\n m := db.Migrator()\n \n if m.HasTable(&model.Node{}) {\n-\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n+\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"ExtraIPs\", \"TCPListenAddr\", \"UDPListenAddr\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n \t\tif m.HasColumn(&model.Node{}, field) {\n \t\t\tcontinue\n \t\t}\n \t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n",
|
||
"before": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\", \"ExtraIPs\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"after": "package repo\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\tgsqlite \"github.com/glebarez/sqlite\"\n\t\"gorm.io/driver/postgres\"\n\t\"gorm.io/gorm\"\n\t\"gorm.io/gorm/clause\"\n\t\"gorm.io/gorm/logger\"\n\n\t\"go-backend/internal/store/model\"\n)\n\n// ─── Type aliases for backward compatibility ─────────────────────────\n// Handlers still reference repo.User, repo.BackupData, etc.\n\ntype User = model.User\ntype ViteConfig = model.ViteConfig\ntype Announcement = model.Announcement\ntype UserTunnelDetail = model.UserTunnelDetail\ntype UserForwardDetail = model.UserForwardDetail\ntype StatisticsFlow = model.StatisticsFlow\ntype Node = model.Node\ntype PeerShare = model.PeerShare\ntype PeerShareRuntime = model.PeerShareRuntime\ntype FederationTunnelBinding = model.FederationTunnelBinding\ntype BackupData = model.BackupData\ntype UserBackup = model.UserBackup\ntype NodeBackup = model.NodeBackup\ntype TunnelBackup = model.TunnelBackup\ntype ChainTunnelBackup = model.ChainTunnelBackup\ntype ForwardBackup = model.ForwardBackup\ntype ForwardPortBackup = model.ForwardPortBackup\ntype UserTunnelBackup = model.UserTunnelBackup\ntype SpeedLimitBackup = model.SpeedLimitBackup\ntype TunnelGroupBackup = model.TunnelGroupBackup\ntype UserGroupBackup = model.UserGroupBackup\ntype PermissionBackup = model.PermissionBackup\ntype PermissionGrantBackup = model.PermissionGrantBackup\ntype ImportResult = model.ImportResult\n\n// ─── Repository ──────────────────────────────────────────────────────\n\ntype Repository struct {\n\tdb *gorm.DB\n}\n\nfunc (r *Repository) DB() *gorm.DB {\n\tif r == nil {\n\t\treturn nil\n\t}\n\treturn r.db\n}\n\n// ─── Open / Close ────────────────────────────────────────────────────\n\nfunc Open(path string) (*Repository, error) {\n\tif err := ensureParentDir(path); err != nil {\n\t\treturn nil, err\n\t}\n\n\tdsn := \"file:\" + path +\n\t\t\"?_pragma=busy_timeout(5000)\" +\n\t\t\"&_pragma=journal_mode(WAL)\" +\n\t\t\"&_pragma=synchronous(NORMAL)\"\n\n\tdb, err := gorm.Open(gsqlite.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tsqlDB.SetMaxOpenConns(1)\n\n\tif err := prepareSQLiteLegacyColumns(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare sqlite legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc OpenPostgres(dsn string) (*Repository, error) {\n\tif strings.TrimSpace(dsn) == \"\" {\n\t\treturn nil, fmt.Errorf(\"empty postgres dsn\")\n\t}\n\n\tdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{\n\t\tLogger: logger.Default.LogMode(logger.Silent),\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsqlDB, err := db.DB()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := sqlDB.Ping(); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\tif err := preparePostgresLegacySchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"prepare postgres legacy schema: %w\", err)\n\t}\n\n\tif err := autoMigrateAll(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, fmt.Errorf(\"auto migrate: %w\", err)\n\t}\n\n\tseedData(db)\n\n\tif err := migrateSchema(db); err != nil {\n\t\t_ = sqlDB.Close()\n\t\treturn nil, err\n\t}\n\n\treturn &Repository{db: db}, nil\n}\n\nfunc (r *Repository) Close() error {\n\tif r == nil || r.db == nil {\n\t\treturn nil\n\t}\n\tsqlDB, err := r.db.DB()\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn sqlDB.Close()\n}\n\nfunc autoMigrateAll(db *gorm.DB) error {\n\tmodels := []interface{}{\n\t\t&model.User{},\n\t\t&model.Forward{},\n\t\t&model.ForwardPort{},\n\t\t&model.Node{},\n\t\t&model.SpeedLimit{},\n\t\t&model.StatisticsFlow{},\n\t\t&model.Tunnel{},\n\t\t&model.ChainTunnel{},\n\t\t&model.UserTunnel{},\n\t\t&model.TunnelGroup{},\n\t\t&model.UserGroup{},\n\t\t&model.TunnelGroupTunnel{},\n\t\t&model.UserGroupUser{},\n\t\t&model.GroupPermission{},\n\t\t&model.GroupPermissionGrant{},\n\t\t&model.ViteConfig{},\n\t\t&model.PeerShare{},\n\t\t&model.PeerShareRuntime{},\n\t\t&model.FederationTunnelBinding{},\n\t\t&model.Announcement{},\n\t\t&model.SchemaVersion{},\n\t}\n\n\tif db.Dialector.Name() != \"sqlite\" {\n\t\treturn db.AutoMigrate(models...)\n\t}\n\n\tm := db.Migrator()\n\thasNode := m.HasTable(&model.Node{})\n\thasTunnel := m.HasTable(&model.Tunnel{})\n\n\tfor _, item := range models {\n\t\tif hasNode {\n\t\t\tif _, ok := item.(*model.Node); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif hasTunnel {\n\t\t\tif _, ok := item.(*model.Tunnel); ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := db.AutoMigrate(item); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// preparePostgresLegacySchema renames unique constraints that were created by\n// the old schema.sql (which used inline UNIQUE column syntax) to the names\n// expected by GORM's NamingStrategy (\"uni_<table>_<column>\"). Without this,\n// GORM's AutoMigrate emits \"DROP CONSTRAINT uni_...\" against constraints that\n// don't exist under that name, crashing startup on upgraded PostgreSQL installs.\nfunc preparePostgresLegacySchema(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype rename struct{ table, oldName, newName string }\n\trenames := []rename{\n\t\t{\"vite_config\", \"vite_config_name_key\", \"uni_vite_config_name\"},\n\t\t{\"peer_share\", \"peer_share_token_key\", \"uni_peer_share_token\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_reservation_id_key\", \"uni_peer_share_runtime_reservation_id\"},\n\t\t{\"peer_share_runtime\", \"peer_share_runtime_resource_key_key\", \"uni_peer_share_runtime_resource_key\"},\n\t\t{\"federation_tunnel_binding\", \"federation_tunnel_binding_resource_key_key\", \"uni_federation_tunnel_binding_resource_key\"},\n\t}\n\n\tfor _, r := range renames {\n\t\tvar count int64\n\t\tif err := db.Raw(\n\t\t\t`SELECT COUNT(*) FROM information_schema.table_constraints\n\t\t\t WHERE constraint_schema = current_schema()\n\t\t\t AND table_name = ?\n\t\t\t AND constraint_name = ?\n\t\t\t AND constraint_type = 'UNIQUE'`,\n\t\t\tr.table, r.oldName,\n\t\t).Scan(&count).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"check constraint %s.%s: %w\", r.table, r.oldName, err)\n\t\t}\n\t\tif count == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tif err := db.Exec(\n\t\t\tfmt.Sprintf(`ALTER TABLE %q RENAME CONSTRAINT %q TO %q`, r.table, r.oldName, r.newName),\n\t\t).Error; err != nil {\n\t\t\treturn fmt.Errorf(\"rename constraint %s.%s→%s: %w\", r.table, r.oldName, r.newName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc prepareSQLiteLegacyColumns(db *gorm.DB) error {\n\tif db == nil || db.Dialector.Name() != \"sqlite\" {\n\t\treturn nil\n\t}\n\tm := db.Migrator()\n\n\tif m.HasTable(&model.Node{}) {\n\t\tfor _, field := range []string{\"ServerIPV4\", \"ServerIPV6\", \"ExtraIPs\", \"TCPListenAddr\", \"UDPListenAddr\", \"Inx\", \"IsRemote\", \"RemoteURL\", \"RemoteToken\", \"RemoteConfig\"} {\n\t\t\tif m.HasColumn(&model.Node{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Node{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add node.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tif m.HasTable(&model.Tunnel{}) {\n\t\tfor _, field := range []string{\"Inx\", \"IPPreference\"} {\n\t\t\tif m.HasColumn(&model.Tunnel{}, field) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif err := m.AddColumn(&model.Tunnel{}, field); err != nil {\n\t\t\t\treturn fmt.Errorf(\"add tunnel.%s: %w\", field, err)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc seedData(db *gorm.DB) {\n\tadminUser := model.User{\n\t\tID: 1, User: \"admin_user\", Pwd: \"3c85cdebade1c51cf64ca9f3c09d182d\",\n\t\tRoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,\n\t\tFlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,\n\t\tUpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,\n\t}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&adminUser)\n\n\tappNameConfig := model.ViteConfig{ID: 1, Name: \"app_name\", Value: \"flux\", Time: 1755147963000}\n\tdb.Where(\"id = ?\", 1).FirstOrCreate(&appNameConfig)\n}\n\n// ─── User Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) GetUserByUsername(username string) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(`\"user\" = ?`, username).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) GetUserByID(id int64) (*model.User, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar user model.User\n\terr := r.db.Where(\"id = ?\", id).First(&user).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &user, nil\n}\n\nfunc (r *Repository) UsernameExists(username string) (bool, error) {\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ?`, username).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UsernameExistsExceptID(username string, exceptID int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.User{}).Where(`\"user\" = ? AND id != ?`, username, exceptID).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdateUserNameAndPassword(userID int64, username, passwordMD5 string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Updates(map[string]interface{}{\n\t\t\"user\": username,\n\t\t\"pwd\": passwordMD5,\n\t\t\"updated_time\": now,\n\t}).Error\n}\n\n// ─── Config Queries ──────────────────────────────────────────────────\n\nfunc (r *Repository) GetConfigByName(name string) (*model.ViteConfig, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar cfg model.ViteConfig\n\terr := r.db.Where(\"name = ?\", name).First(&cfg).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &cfg, nil\n}\n\nfunc (r *Repository) ListConfigs() (map[string]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar configs []model.ViteConfig\n\tif err := r.db.Find(&configs).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make(map[string]string)\n\tfor _, c := range configs {\n\t\tresult[c.Name] = c.Value\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) UpsertConfig(name, value string, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n}\n\n// ─── Announcement Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetAnnouncement() (*model.Announcement, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ann model.Announcement\n\terr := r.db.Order(\"id DESC\").First(&ann).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ann, nil\n}\n\nfunc (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\tif err := r.db.Model(&model.Announcement{}).Count(&count).Error; err != nil {\n\t\treturn err\n\t}\n\tif count == 0 {\n\t\treturn r.db.Create(&model.Announcement{\n\t\t\tContent: content, Enabled: enabled,\n\t\t\tCreatedTime: now, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t}).Error\n\t}\n\treturn r.db.Model(&model.Announcement{}).Where(\"1=1\").Updates(map[string]interface{}{\n\t\t\"content\": content, \"enabled\": enabled, \"updated_time\": now,\n\t}).Error\n}\n\n// ─── User Package Queries ────────────────────────────────────────────\n\nfunc (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.UserTunnelDetail\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tJoins(\"LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id\").\n\t\tWhere(\"user_tunnel.user_id = ?\", userID).\n\t\tOrder(\"user_tunnel.id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.UserTunnelDetail, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) GetUserPackageForwards(userID int64) ([]model.UserForwardDetail, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tStatus int\n\t\tCreatedAt int64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, forward.in_flow, forward.out_flow, forward.status, forward.created_time AS created_at\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tWhere(\"forward.user_id = ?\", userID).\n\t\tOrder(\"forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]model.UserForwardDetail, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, model.UserForwardDetail{\n\t\t\tID: row.ID, Name: row.Name, TunnelID: row.TunnelID,\n\t\t\tTunnelName: row.TunnelName, InIP: inIP, InPort: inPort,\n\t\t\tRemoteAddr: row.RemoteAddr, InFlow: row.InFlow, OutFlow: row.OutFlow,\n\t\t\tStatus: row.Status, CreatedAt: row.CreatedAt,\n\t\t})\n\t}\n\treturn items, nil\n}\n\n// ─── Statistics Queries ──────────────────────────────────────────────\n\nfunc (r *Repository) GetStatisticsFlows(userID int64, limit int) ([]model.StatisticsFlow, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").Limit(limit).Find(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.StatisticsFlow, 0)\n\t}\n\treturn items, nil\n}\n\n// ─── Node Queries ────────────────────────────────────────────────────\n\nfunc (r *Repository) NodeExistsBySecret(secret string) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.Node{}).Where(\"secret = ?\", secret).Count(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetNodeBySecret(secret string) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"secret = ?\", secret).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) GetNodeByID(id int64) (*model.Node, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar n model.Node\n\terr := r.db.Where(\"id = ?\", id).First(&n).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &n, nil\n}\n\nfunc (r *Repository) UpdateNodeOnline(nodeID int64, status int, version string, httpVal, tlsVal, socksVal int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"version\": version, \"http\": httpVal, \"tls\": tlsVal,\n\t\t\"socks\": socksVal, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\nfunc (r *Repository) UpdateNodeStatus(nodeID int64, status int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.Node{}).Where(\"id = ?\", nodeID).Updates(map[string]interface{}{\n\t\t\"status\": status, \"updated_time\": unixMilliNow(),\n\t}).Error\n}\n\n// ─── Flow ────────────────────────────────────────────────────────────\n\nfunc (r *Repository) AddFlow(forwardID, userID int64, userTunnelID int64, inFlow, outFlow int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\tif err := tx.Model(&model.Forward{}).Where(\"id = ?\", forwardID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := tx.Model(&model.User{}).Where(\"id = ?\", userID).\n\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t}).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif userTunnelID > 0 {\n\t\t\tif err := tx.Model(&model.UserTunnel{}).Where(\"id = ?\", userTunnelID).\n\t\t\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\t\t\"in_flow\": gorm.Expr(\"in_flow + ?\", inFlow),\n\t\t\t\t\t\"out_flow\": gorm.Expr(\"out_flow + ?\", outFlow),\n\t\t\t\t}).Error; err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// ─── List Methods (return map[string]interface{}) ────────────────────\n\nfunc (r *Repository) ListNodes() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": n.ID, \"inx\": n.Inx, \"name\": n.Name,\n\t\t\t\"ip\": n.ServerIP, \"serverIp\": n.ServerIP,\n\t\t\t\"serverIpV4\": nullableString(n.ServerIPV4),\n\t\t\t\"serverIpV6\": nullableString(n.ServerIPV6),\n\t\t\t\"extraIPs\": nullableString(n.ExtraIPs),\n\t\t\t\"port\": n.Port,\n\t\t\t\"tcpListenAddr\": n.TCPListenAddr,\n\t\t\t\"udpListenAddr\": n.UDPListenAddr,\n\t\t\t\"version\": nullableString(n.Version),\n\t\t\t\"http\": n.HTTP, \"tls\": n.TLS, \"socks\": n.Socks,\n\t\t\t\"status\": n.Status, \"isRemote\": n.IsRemote,\n\t\t\t\"remoteUrl\": nullableString(n.RemoteURL),\n\t\t\t\"remoteToken\": nullableString(n.RemoteToken),\n\t\t\t\"remoteConfig\": nullableString(n.RemoteConfig),\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUsers() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\tif err := r.db.Where(\"role_id != ?\", 0).Order(\"id DESC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(users))\n\tfor _, u := range users {\n\t\titems = append(items, map[string]interface{}{\n\t\t\t\"id\": u.ID, \"user\": u.User, \"name\": u.User,\n\t\t\t\"roleId\": u.RoleID, \"status\": u.Status,\n\t\t\t\"flow\": u.Flow, \"num\": u.Num, \"expTime\": u.ExpTime,\n\t\t\t\"flowResetTime\": u.FlowResetTime, \"createdTime\": u.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(u.UpdatedTime),\n\t\t\t\"inFlow\": u.InFlow, \"outFlow\": u.OutFlow,\n\t\t})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListSpeedLimits() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar limits []model.SpeedLimit\n\tif err := r.db.Order(\"id DESC\").Find(&limits).Error; err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(limits))\n\tfor _, sl := range limits {\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": sl.ID, \"name\": sl.Name, \"speed\": sl.Speed,\n\t\t\t\"status\": sl.Status, \"createdTime\": sl.CreatedTime,\n\t\t\t\"updatedTime\": nullableInt64(sl.UpdatedTime),\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListForwards() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype fwdRow struct {\n\t\tID int64\n\t\tUserID int64\n\t\tUserName string\n\t\tName string\n\t\tTunnelID int64\n\t\tTunnelName string\n\t\tRemoteAddr string\n\t\tStrategy string\n\t\tInFlow int64\n\t\tOutFlow int64\n\t\tCreatedTime int64\n\t\tStatus int\n\t\tInx int\n\t\tSpeedID sql.NullInt64\n\t}\n\n\tvar rows []fwdRow\n\terr := r.db.Model(&model.Forward{}).\n\t\tSelect(\"forward.id, forward.user_id, forward.user_name, forward.name, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward.remote_addr, COALESCE(forward.strategy, 'fifo') AS strategy, forward.in_flow, forward.out_flow, forward.created_time, forward.status, forward.inx, forward.speed_id\").\n\t\tJoins(\"LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id\").\n\t\tOrder(\"forward.inx ASC, forward.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, row := range rows {\n\t\tinIP, inPort, err := resolveForwardIngress(r.db, row.ID, row.TunnelID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem := map[string]interface{}{\n\t\t\t\"id\": row.ID, \"userId\": row.UserID, \"userName\": row.UserName,\n\t\t\t\"name\": row.Name, \"tunnelId\": row.TunnelID, \"tunnelName\": row.TunnelName,\n\t\t\t\"inIp\": nullableForwardIngress(inIP), \"inPort\": nullableInt64(inPort),\n\t\t\t\"remoteAddr\": row.RemoteAddr, \"strategy\": row.Strategy,\n\t\t\t\"inFlow\": row.InFlow, \"outFlow\": row.OutFlow,\n\t\t\t\"createdTime\": row.CreatedTime, \"status\": row.Status, \"inx\": int64(row.Inx),\n\t\t}\n\t\tif row.SpeedID.Valid {\n\t\t\titem[\"speedId\"] = row.SpeedID.Int64\n\t\t}\n\t\titems = append(items, item)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListUserAccessibleTunnels(userID int64) ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id\").\n\t\tWhere(\"user_tunnel.user_id = ? AND tunnel.status = 1\", userID).\n\t\tOrder(\"tunnel.inx ASC, tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListEnabledTunnelSummaries() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.Tunnel{}).Select(\"id, name\").Where(\"status = 1\").Order(\"inx ASC, id ASC\").Find(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\titems := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\titems = append(items, map[string]interface{}{\"id\": r.ID, \"name\": r.Name})\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListTunnels() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\ttunnelMap := make(map[int64]map[string]interface{})\n\torderedIDs := make([]int64, 0, len(tunnels))\n\n\tfor _, t := range tunnels {\n\t\ttunnelMap[t.ID] = map[string]interface{}{\n\t\t\t\"id\": t.ID, \"inx\": t.Inx, \"name\": t.Name,\n\t\t\t\"type\": t.Type, \"flow\": t.Flow, \"trafficRatio\": t.TrafficRatio,\n\t\t\t\"status\": t.Status, \"createdTime\": t.CreatedTime,\n\t\t\t\"inIp\": nullableString(t.InIP),\n\t\t\t\"ipPreference\": t.IPPreference,\n\t\t\t\"inNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"outNodeId\": make([]map[string]interface{}, 0),\n\t\t\t\"chainNodes\": make([][]map[string]interface{}, 0),\n\t\t}\n\t\torderedIDs = append(orderedIDs, t.ID)\n\t}\n\n\t// Build node IP map\n\tnodeIPMap := map[int64]string{}\n\tvar nodeList []model.Node\n\tif err := r.db.Select(\"id, server_ip\").Find(&nodeList).Error; err == nil {\n\t\tfor _, n := range nodeList {\n\t\t\tnodeIPMap[n.ID] = n.ServerIP\n\t\t}\n\t}\n\n\t// Load chain tunnels\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Order(\"tunnel_id ASC, chain_type ASC, inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\n\tchainBucket := map[int64]map[int][]map[string]interface{}{}\n\tinNodeIPs := map[int64][]string{}\n\n\tfor _, c := range chains {\n\t\tt, ok := tunnelMap[c.TunnelID]\n\t\tif !ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tchainTypeInt := 0\n\t\tfmt.Sscanf(c.ChainType, \"%d\", &chainTypeInt)\n\n\t\tinx := int64(0)\n\t\tif c.Inx.Valid {\n\t\t\tinx = c.Inx.Int64\n\t\t}\n\n\t\tnodeObj := map[string]interface{}{\n\t\t\t\"nodeId\": c.NodeID,\n\t\t\t\"chainType\": chainTypeInt,\n\t\t\t\"inx\": inx,\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tnodeObj[\"protocol\"] = c.Protocol.String\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tnodeObj[\"strategy\"] = c.Strategy.String\n\t\t}\n\t\tif c.ConnectIP.Valid {\n\t\t\tnodeObj[\"connectIp\"] = c.ConnectIP.String\n\t\t}\n\n\t\tswitch chainTypeInt {\n\t\tcase 1:\n\t\t\tt[\"inNodeId\"] = append(t[\"inNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t\tif ip, ok := nodeIPMap[c.NodeID]; ok && ip != \"\" {\n\t\t\t\tinNodeIPs[c.TunnelID] = append(inNodeIPs[c.TunnelID], ip)\n\t\t\t}\n\t\tcase 2:\n\t\t\tif _, ok := chainBucket[c.TunnelID]; !ok {\n\t\t\t\tchainBucket[c.TunnelID] = map[int][]map[string]interface{}{}\n\t\t\t}\n\t\t\tchainBucket[c.TunnelID][int(inx)] = append(chainBucket[c.TunnelID][int(inx)], nodeObj)\n\t\tcase 3:\n\t\t\tt[\"outNodeId\"] = append(t[\"outNodeId\"].([]map[string]interface{}), nodeObj)\n\t\t}\n\t}\n\n\tfor tunnelID, groups := range chainBucket {\n\t\tt := tunnelMap[tunnelID]\n\t\tif t == nil {\n\t\t\tcontinue\n\t\t}\n\t\tkeys := make([]int, 0, len(groups))\n\t\tfor k := range groups {\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t\tsort.Ints(keys)\n\t\tordered := make([][]map[string]interface{}, 0, len(keys))\n\t\tfor _, k := range keys {\n\t\t\tordered = append(ordered, groups[k])\n\t\t}\n\t\tt[\"chainNodes\"] = ordered\n\n\t\tif s, ok := t[\"inIp\"].(string); !ok || strings.TrimSpace(s) == \"\" {\n\t\t\tif ips := inNodeIPs[tunnelID]; len(ips) > 0 {\n\t\t\t\tt[\"inIp\"] = strings.Join(ips, \",\")\n\t\t\t}\n\t\t}\n\t}\n\n\tresult := make([]map[string]interface{}, 0, len(orderedIDs))\n\tfor _, id := range orderedIDs {\n\t\tif t, ok := tunnelMap[id]; ok {\n\t\t\tresult = append(result, t)\n\t\t}\n\t}\n\treturn result, nil\n}\n\n// ─── Group Queries ───────────────────────────────────────────────────\n\nfunc (r *Repository) ListTunnelGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listTunnelGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"tunnelIds\": ids, \"tunnelNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListUserGroups() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(groups))\n\tfor _, g := range groups {\n\t\tids, names, err := r.listUserGroupMembers(g.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": g.ID, \"name\": g.Name, \"status\": g.Status,\n\t\t\t\"userIds\": ids, \"userNames\": names,\n\t\t\t\"createdTime\": g.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) ListGroupPermissions() ([]map[string]interface{}, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\n\ttype permRow struct {\n\t\tID int64\n\t\tUserGroupID int64\n\t\tUserGroupName sql.NullString\n\t\tTunnelGroupID int64\n\t\tTunnelGroupName sql.NullString\n\t\tCreatedTime int64\n\t}\n\tvar rows []permRow\n\terr := r.db.Model(&model.GroupPermission{}).\n\t\tSelect(\"group_permission.id, group_permission.user_group_id, user_group.name AS user_group_name, group_permission.tunnel_group_id, tunnel_group.name AS tunnel_group_name, group_permission.created_time\").\n\t\tJoins(\"LEFT JOIN user_group ON user_group.id = group_permission.user_group_id\").\n\t\tJoins(\"LEFT JOIN tunnel_group ON tunnel_group.id = group_permission.tunnel_group_id\").\n\t\tOrder(\"group_permission.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tresult := make([]map[string]interface{}, 0, len(rows))\n\tfor _, r := range rows {\n\t\tresult = append(result, map[string]interface{}{\n\t\t\t\"id\": r.ID, \"userGroupId\": r.UserGroupID,\n\t\t\t\"userGroupName\": nullableString(r.UserGroupName),\n\t\t\t\"tunnelGroupId\": r.TunnelGroupID,\n\t\t\t\"tunnelGroupName\": nullableString(r.TunnelGroupName),\n\t\t\t\"createdTime\": r.CreatedTime,\n\t\t})\n\t}\n\treturn result, nil\n}\n\nfunc (r *Repository) listTunnelGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.TunnelGroupTunnel{}).\n\t\tSelect(\"tunnel.id, tunnel.name\").\n\t\tJoins(\"JOIN tunnel ON tunnel.id = tunnel_group_tunnel.tunnel_id\").\n\t\tWhere(\"tunnel_group_tunnel.tunnel_group_id = ?\", groupID).\n\t\tOrder(\"tunnel.id ASC\").\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\nfunc (r *Repository) listUserGroupMembers(groupID int64) ([]int64, []string, error) {\n\ttype row struct {\n\t\tID int64\n\t\tName string\n\t}\n\tvar rows []row\n\terr := r.db.Model(&model.UserGroupUser{}).\n\t\tSelect(`\"user\".id, \"user\".\"user\" AS name`).\n\t\tJoins(`JOIN \"user\" ON \"user\".id = user_group_user.user_id`).\n\t\tWhere(\"user_group_user.user_group_id = ?\", groupID).\n\t\tOrder(`\"user\".id ASC`).\n\t\tFind(&rows).Error\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\tids := make([]int64, 0, len(rows))\n\tnames := make([]string, 0, len(rows))\n\tfor _, r := range rows {\n\t\tids = append(ids, r.ID)\n\t\tnames = append(names, r.Name)\n\t}\n\treturn ids, names, nil\n}\n\n// ─── PeerShare CRUD ──────────────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(share).Error\n}\n\nfunc (r *Repository) UpdatePeerShare(share *model.PeerShare) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", share.ID).Updates(map[string]interface{}{\n\t\t\"name\": share.Name, \"max_bandwidth\": share.MaxBandwidth,\n\t\t\"expiry_time\": share.ExpiryTime, \"port_range_start\": share.PortRangeStart,\n\t\t\"port_range_end\": share.PortRangeEnd, \"is_active\": share.IsActive,\n\t\t\"updated_time\": share.UpdatedTime, \"allowed_domains\": share.AllowedDomains,\n\t\t\"allowed_ips\": share.AllowedIPs,\n\t}).Error\n}\n\nfunc (r *Repository) DeletePeerShare(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Transaction(func(tx *gorm.DB) error {\n\t\ttx.Where(\"share_id = ?\", id).Delete(&model.PeerShareRuntime{})\n\t\treturn tx.Where(\"id = ?\", id).Delete(&model.PeerShare{}).Error\n\t})\n}\n\nfunc (r *Repository) GetPeerShare(id int64) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"id = ?\", id).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) GetPeerShareByToken(token string) (*model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar s model.PeerShare\n\terr := r.db.Where(\"token = ?\", token).First(&s).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &s, nil\n}\n\nfunc (r *Repository) ListPeerShares() ([]model.PeerShare, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar shares []model.PeerShare\n\terr := r.db.Order(\"id DESC\").Find(&shares).Error\n\treturn shares, err\n}\n\nfunc (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || delta <= 0 {\n\t\treturn nil\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"current_flow\": gorm.Expr(\"current_flow + ?\", delta),\n\t\t\t\"updated_time\": unixMilliNow(),\n\t\t}).Error\n}\n\nfunc (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShare{}).Where(\"id = ?\", shareID).Updates(map[string]interface{}{\n\t\t\"current_flow\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\n// ─── PeerShareRuntime CRUD ───────────────────────────────────────────\n\nfunc (r *Repository) CreatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Create(item).Error\n}\n\nfunc (r *Repository) UpdatePeerShareRuntime(item *model.PeerShareRuntime) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"runtime item is nil\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", item.ID).Updates(map[string]interface{}{\n\t\t\"binding_id\": item.BindingID, \"role\": item.Role,\n\t\t\"chain_name\": item.ChainName, \"service_name\": item.ServiceName,\n\t\t\"protocol\": item.Protocol, \"strategy\": item.Strategy,\n\t\t\"port\": item.Port, \"target\": item.Target,\n\t\t\"applied\": item.Applied, \"status\": item.Status,\n\t\t\"updated_time\": item.UpdatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"status\": 0, \"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND resource_key = ?\", shareID, resourceKey).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND reservation_id = ?\", shareID, reservationID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND binding_id = ?\", shareID, bindingID).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetPeerShareRuntimeByID(id int64) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"id = ?\", id).First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND status = 1\", shareID).Order(\"port ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ports []int\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND node_id = ? AND status = 1 AND port > 0\", shareID, nodeID).\n\t\tPluck(\"port\", &ports).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif ports == nil {\n\t\tports = make([]int, 0)\n\t}\n\treturn ports, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"service_name = ? AND status = 1 AND role = ?\", serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif serviceName == \"\" {\n\t\treturn []model.PeerShareRuntime{}, nil\n\t}\n\tvar items []model.PeerShareRuntime\n\terr := r.db.Where(\"node_id = ? AND service_name = ? AND status = 1 AND role = ?\", nodeID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFind(&items).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif items == nil {\n\t\titems = make([]model.PeerShareRuntime, 0)\n\t}\n\treturn items, nil\n}\n\nfunc (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar names []string\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND service_name <> ''\", nodeID, \"forward\").\n\t\tPluck(\"service_name\", &names).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif names == nil {\n\t\tnames = make([]string, 0)\n\t}\n\treturn names, nil\n}\n\nfunc (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)\", nodeID, \"forward\", minUpdatedTime).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND port = ? AND status = 1 AND role = ?\", shareID, port, \"forward\").First(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil, nil\n\t}\n\tvar item model.PeerShareRuntime\n\terr := r.db.Where(\"share_id = ? AND service_name = ? AND status = 1 AND role = ?\", shareID, serviceName, \"forward\").\n\t\tOrder(\"id ASC\").\n\t\tFirst(&item).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &item, nil\n}\n\nfunc (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {\n\tif r == nil || r.db == nil {\n\t\treturn false, errors.New(\"repository not initialized\")\n\t}\n\tvar count int64\n\terr := r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"node_id = ? AND port = ? AND status = 1\", nodeID, port).\n\t\tCount(&count).Error\n\tif err != nil {\n\t\treturn false, err\n\t}\n\treturn count > 0, nil\n}\n\nfunc (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"id = ?\", id).Updates(map[string]interface{}{\n\t\t\"service_name\": serviceName,\n\t\t\"applied\": 1,\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif shareID <= 0 || port <= 0 {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).Where(\"share_id = ? AND port = ? AND status = 1\", shareID, port).Updates(map[string]interface{}{\n\t\t\"status\": 0,\n\t\t\"applied\": 0,\n\t\t\"service_name\": \"\",\n\t\t\"updated_time\": updatedTime,\n\t}).Error\n}\n\nfunc (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tserviceName = strings.TrimSpace(serviceName)\n\tif shareID <= 0 || serviceName == \"\" {\n\t\treturn nil\n\t}\n\tif updatedTime <= 0 {\n\t\tupdatedTime = unixMilliNow()\n\t}\n\treturn r.db.Model(&model.PeerShareRuntime{}).\n\t\tWhere(\"share_id = ? AND status = 1 AND role = ? AND service_name = ?\", shareID, \"forward\", serviceName).\n\t\tUpdates(map[string]interface{}{\n\t\t\t\"status\": 0,\n\t\t\t\"applied\": 0,\n\t\t\t\"service_name\": \"\",\n\t\t\t\"updated_time\": updatedTime,\n\t\t}).Error\n}\n\n// ─── FederationTunnelBinding ─────────────────────────────────────────\n\nfunc (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tif item == nil {\n\t\treturn errors.New(\"binding item is nil\")\n\t}\n\treturn r.db.Clauses(clause.OnConflict{\n\t\tColumns: []clause.Column{\n\t\t\t{Name: \"tunnel_id\"}, {Name: \"node_id\"}, {Name: \"chain_type\"}, {Name: \"hop_inx\"},\n\t\t},\n\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\"remote_url\", \"resource_key\", \"remote_binding_id\",\n\t\t\t\"allocated_port\", \"status\", \"updated_time\",\n\t\t}),\n\t}).Create(item).Error\n}\n\nfunc (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]model.FederationTunnelBinding, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar out []model.FederationTunnelBinding\n\terr := r.db.Where(\"tunnel_id = ? AND status = 1\", tunnelID).\n\t\tOrder(\"chain_type ASC, hop_inx ASC, id ASC\").Find(&out).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif out == nil {\n\t\tout = make([]model.FederationTunnelBinding, 0)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"tunnel_id = ?\", tunnelID).Delete(&model.FederationTunnelBinding{}).Error\n}\n\n// ─── Export Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) ExportAll() (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\n\tusers, err := r.exportUsers()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t}\n\tbackup.Users = users\n\n\tnodes, err := r.exportNodes()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t}\n\tbackup.Nodes = nodes\n\n\ttunnels, err := r.exportTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t}\n\tbackup.Tunnels = tunnels\n\n\tforwards, err := r.exportForwards()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t}\n\tbackup.Forwards = forwards\n\n\tuserTunnels, err := r.exportUserTunnels()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t}\n\tbackup.UserTunnels = userTunnels\n\n\tspeedLimits, err := r.exportSpeedLimits()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t}\n\tbackup.SpeedLimits = speedLimits\n\n\ttunnelGroups, err := r.exportTunnelGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t}\n\tbackup.TunnelGroups = tunnelGroups\n\n\tuserGroups, err := r.exportUserGroups()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t}\n\tbackup.UserGroups = userGroups\n\n\tpermissions, err := r.exportPermissions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t}\n\tbackup.Permissions = permissions\n\n\tconfigs, err := r.ListConfigs()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t}\n\tbackup.Configs = configs\n\n\treturn backup, nil\n}\n\nfunc (r *Repository) ExportPartial(types []string) (*model.BackupData, error) {\n\tbackup := &model.BackupData{Version: \"1.0\", ExportedAt: unixMilliNow()}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\tif typeSet[\"users\"] {\n\t\tv, err := r.exportUsers()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export users failed: %w\", err)\n\t\t}\n\t\tbackup.Users = v\n\t}\n\tif typeSet[\"nodes\"] {\n\t\tv, err := r.exportNodes()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export nodes failed: %w\", err)\n\t\t}\n\t\tbackup.Nodes = v\n\t}\n\tif typeSet[\"tunnels\"] {\n\t\tv, err := r.exportTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.Tunnels = v\n\t}\n\tif typeSet[\"forwards\"] {\n\t\tv, err := r.exportForwards()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export forwards failed: %w\", err)\n\t\t}\n\t\tbackup.Forwards = v\n\t}\n\tif typeSet[\"userTunnels\"] {\n\t\tv, err := r.exportUserTunnels()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user tunnels failed: %w\", err)\n\t\t}\n\t\tbackup.UserTunnels = v\n\t}\n\tif typeSet[\"speedLimits\"] {\n\t\tv, err := r.exportSpeedLimits()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export speed limits failed: %w\", err)\n\t\t}\n\t\tbackup.SpeedLimits = v\n\t}\n\tif typeSet[\"tunnelGroups\"] {\n\t\tv, err := r.exportTunnelGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export tunnel groups failed: %w\", err)\n\t\t}\n\t\tbackup.TunnelGroups = v\n\t}\n\tif typeSet[\"userGroups\"] {\n\t\tv, err := r.exportUserGroups()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export user groups failed: %w\", err)\n\t\t}\n\t\tbackup.UserGroups = v\n\t}\n\tif typeSet[\"permissions\"] {\n\t\tv, err := r.exportPermissions()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export permissions failed: %w\", err)\n\t\t}\n\t\tbackup.Permissions = v\n\t}\n\tif typeSet[\"configs\"] {\n\t\tv, err := r.ListConfigs()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"export configs failed: %w\", err)\n\t\t}\n\t\tbackup.Configs = v\n\t}\n\treturn backup, nil\n}\n\nfunc (r *Repository) exportUsers() ([]model.UserBackup, error) {\n\tvar users []model.User\n\tif err := r.db.Order(\"id ASC\").Find(&users).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserBackup, 0, len(users))\n\tfor _, u := range users {\n\t\tb := model.UserBackup{\n\t\t\tID: u.ID, User: u.User, Pwd: u.Pwd, RoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime, Flow: u.Flow, InFlow: u.InFlow, OutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime, Num: u.Num,\n\t\t\tCreatedTime: u.CreatedTime, Status: u.Status,\n\t\t}\n\t\tif u.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = u.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportNodes() ([]model.NodeBackup, error) {\n\tvar nodes []model.Node\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&nodes).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.NodeBackup, 0, len(nodes))\n\tfor _, n := range nodes {\n\t\tb := model.NodeBackup{\n\t\t\tID: n.ID, Name: n.Name, Secret: n.Secret, ServerIP: n.ServerIP,\n\t\t\tPort: n.Port, HTTP: n.HTTP, TLS: n.TLS, Socks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime, Status: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr, UDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx, IsRemote: n.IsRemote,\n\t\t}\n\t\tif n.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = n.UpdatedTime.Int64\n\t\t}\n\t\tif n.ServerIPV4.Valid {\n\t\t\tb.ServerIPv4 = n.ServerIPV4.String\n\t\t}\n\t\tif n.ServerIPV6.Valid {\n\t\t\tb.ServerIPv6 = n.ServerIPV6.String\n\t\t}\n\t\tif n.InterfaceName.Valid {\n\t\t\tb.InterfaceName = n.InterfaceName.String\n\t\t}\n\t\tif n.Version.Valid {\n\t\t\tb.Version = n.Version.String\n\t\t}\n\t\tif n.RemoteURL.Valid {\n\t\t\tb.RemoteURL = n.RemoteURL.String\n\t\t}\n\t\tif n.RemoteToken.Valid {\n\t\t\tb.RemoteToken = n.RemoteToken.String\n\t\t}\n\t\tif n.RemoteConfig.Valid {\n\t\t\tb.RemoteConfig = n.RemoteConfig.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnels() ([]model.TunnelBackup, error) {\n\tvar tunnels []model.Tunnel\n\tif err := r.db.Order(\"inx ASC, id ASC\").Find(&tunnels).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelBackup, 0, len(tunnels))\n\tfor _, t := range tunnels {\n\t\tb := model.TunnelBackup{\n\t\t\tID: t.ID, Name: t.Name, TrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type, Protocol: t.Protocol, Flow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime, UpdatedTime: t.UpdatedTime,\n\t\t\tStatus: t.Status, Inx: t.Inx, IPPreference: t.IPPreference,\n\t\t}\n\t\tif t.InIP.Valid {\n\t\t\tb.InIP = t.InIP.String\n\t\t}\n\t\tchains, err := r.exportChainTunnels(t.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tb.ChainTunnels = chains\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportChainTunnels(tunnelID int64) ([]model.ChainTunnelBackup, error) {\n\tvar chains []model.ChainTunnel\n\tif err := r.db.Where(\"tunnel_id = ?\", tunnelID).Order(\"inx ASC, id ASC\").Find(&chains).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ChainTunnelBackup, 0, len(chains))\n\tfor _, c := range chains {\n\t\tb := model.ChainTunnelBackup{\n\t\t\tID: c.ID, TunnelID: c.TunnelID, ChainType: c.ChainType, NodeID: c.NodeID,\n\t\t}\n\t\tif c.Port.Valid {\n\t\t\tb.Port = int(c.Port.Int64)\n\t\t}\n\t\tif c.Strategy.Valid {\n\t\t\tb.Strategy = c.Strategy.String\n\t\t}\n\t\tif c.Inx.Valid {\n\t\t\tb.Inx = int(c.Inx.Int64)\n\t\t}\n\t\tif c.Protocol.Valid {\n\t\t\tb.Protocol = c.Protocol.String\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwards() ([]model.ForwardBackup, error) {\n\tvar forwards []model.Forward\n\tif err := r.db.Order(\"id ASC\").Find(&forwards).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardBackup, 0, len(forwards))\n\tfor _, f := range forwards {\n\t\tb := model.ForwardBackup{\n\t\t\tID: f.ID, UserID: f.UserID, UserName: f.UserName, Name: f.Name,\n\t\t\tTunnelID: f.TunnelID, RemoteAddr: f.RemoteAddr, Strategy: f.Strategy,\n\t\t\tInFlow: f.InFlow, OutFlow: f.OutFlow, CreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: f.UpdatedTime, Status: f.Status, Inx: f.Inx,\n\t\t}\n\t\tports, err := r.exportForwardPorts(f.ID)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tportsCopy := append([]model.ForwardPortBackup(nil), ports...)\n\t\tb.ForwardPorts = &portsCopy\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportForwardPorts(forwardID int64) ([]model.ForwardPortBackup, error) {\n\tvar fps []model.ForwardPort\n\tif err := r.db.Where(\"forward_id = ?\", forwardID).Order(\"id ASC\").Find(&fps).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ForwardPortBackup, 0, len(fps))\n\tfor _, fp := range fps {\n\t\tout = append(out, model.ForwardPortBackup{NodeID: fp.NodeID, Port: fp.Port})\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserTunnels() ([]model.UserTunnelBackup, error) {\n\tvar uts []model.UserTunnel\n\tif err := r.db.Order(\"id ASC\").Find(&uts).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserTunnelBackup, 0, len(uts))\n\tfor _, ut := range uts {\n\t\tb := model.UserTunnelBackup{\n\t\t\tID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID,\n\t\t\tNum: ut.Num, Flow: ut.Flow, InFlow: ut.InFlow, OutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime, ExpTime: ut.ExpTime, Status: ut.Status,\n\t\t}\n\t\tif ut.SpeedID.Valid {\n\t\t\tb.SpeedID = ut.SpeedID.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportSpeedLimits() ([]model.SpeedLimitBackup, error) {\n\tvar sls []model.SpeedLimit\n\tif err := r.db.Order(\"id ASC\").Find(&sls).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.SpeedLimitBackup, 0, len(sls))\n\tfor _, sl := range sls {\n\t\tb := model.SpeedLimitBackup{\n\t\t\tID: sl.ID, Name: sl.Name, Speed: int64(sl.Speed),\n\t\t\tCreatedTime: sl.CreatedTime, Status: sl.Status,\n\t\t}\n\t\tif sl.UpdatedTime.Valid {\n\t\t\tb.UpdatedTime = sl.UpdatedTime.Int64\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportTunnelGroups() ([]model.TunnelGroupBackup, error) {\n\tvar groups []model.TunnelGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.TunnelGroupBackup, 0, len(groups))\n\tfor _, tg := range groups {\n\t\tb := model.TunnelGroupBackup{\n\t\t\tID: tg.ID, Name: tg.Name, CreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: tg.UpdatedTime, Status: tg.Status,\n\t\t}\n\t\tvar tunnelIDs []int64\n\t\tr.db.Model(&model.TunnelGroupTunnel{}).Where(\"tunnel_group_id = ?\", tg.ID).Pluck(\"tunnel_id\", &tunnelIDs)\n\t\tb.Tunnels = tunnelIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportUserGroups() ([]model.UserGroupBackup, error) {\n\tvar groups []model.UserGroup\n\tif err := r.db.Order(\"id ASC\").Find(&groups).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserGroupBackup, 0, len(groups))\n\tfor _, ug := range groups {\n\t\tb := model.UserGroupBackup{\n\t\t\tID: ug.ID, Name: ug.Name, CreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: ug.UpdatedTime, Status: ug.Status,\n\t\t}\n\t\tvar userIDs []int64\n\t\tr.db.Model(&model.UserGroupUser{}).Where(\"user_group_id = ?\", ug.ID).Pluck(\"user_id\", &userIDs)\n\t\tb.Users = userIDs\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) exportPermissions() ([]model.PermissionBackup, error) {\n\tvar perms []model.GroupPermission\n\tif err := r.db.Order(\"id ASC\").Find(&perms).Error; err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.PermissionBackup, 0, len(perms))\n\tfor _, p := range perms {\n\t\tb := model.PermissionBackup{\n\t\t\tID: p.ID, UserGroupID: p.UserGroupID, TunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\tvar grants []model.GroupPermissionGrant\n\t\tr.db.Where(\"user_group_id = ? AND tunnel_group_id = ?\", p.UserGroupID, p.TunnelGroupID).Find(&grants)\n\t\tfor _, g := range grants {\n\t\t\tb.Grants = append(b.Grants, model.PermissionGrantBackup{\n\t\t\t\tID: g.ID, UserGroupID: g.UserGroupID, TunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID, CreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t})\n\t\t}\n\t\tout = append(out, b)\n\t}\n\treturn out, nil\n}\n\n// ─── Import Methods ──────────────────────────────────────────────────\n\nfunc (r *Repository) Import(backup *model.BackupData, types []string) (*model.ImportResult, error) {\n\tresult := &model.ImportResult{}\n\ttypeSet := make(map[string]bool)\n\tfor _, t := range types {\n\t\ttypeSet[t] = true\n\t}\n\n\terr := r.db.Transaction(func(tx *gorm.DB) error {\n\t\tnow := unixMilliNow()\n\n\t\tif typeSet[\"users\"] && len(backup.Users) > 0 {\n\t\t\tcount, err := importUsers(tx, backup.Users, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import users failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UsersImported = count\n\t\t}\n\t\tif typeSet[\"nodes\"] && len(backup.Nodes) > 0 {\n\t\t\tcount, err := importNodes(tx, backup.Nodes, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import nodes failed: %w\", err)\n\t\t\t}\n\t\t\tresult.NodesImported = count\n\t\t}\n\t\tif typeSet[\"tunnels\"] && len(backup.Tunnels) > 0 {\n\t\t\tcount, err := importTunnels(tx, backup.Tunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelsImported = count\n\t\t}\n\t\tif typeSet[\"forwards\"] && len(backup.Forwards) > 0 {\n\t\t\tcount, err := importForwards(tx, backup.Forwards, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import forwards failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ForwardsImported = count\n\t\t}\n\t\tif typeSet[\"userTunnels\"] && len(backup.UserTunnels) > 0 {\n\t\t\tcount, err := importUserTunnels(tx, backup.UserTunnels, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user tunnels failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserTunnelsImported = count\n\t\t}\n\t\tif typeSet[\"speedLimits\"] && len(backup.SpeedLimits) > 0 {\n\t\t\tcount, err := importSpeedLimits(tx, backup.SpeedLimits, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import speed limits failed: %w\", err)\n\t\t\t}\n\t\t\tresult.SpeedLimitsImported = count\n\t\t}\n\t\tif typeSet[\"tunnelGroups\"] && len(backup.TunnelGroups) > 0 {\n\t\t\tcount, err := importTunnelGroups(tx, backup.TunnelGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import tunnel groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.TunnelGroupsImported = count\n\t\t}\n\t\tif typeSet[\"userGroups\"] && len(backup.UserGroups) > 0 {\n\t\t\tcount, err := importUserGroups(tx, backup.UserGroups, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import user groups failed: %w\", err)\n\t\t\t}\n\t\t\tresult.UserGroupsImported = count\n\t\t}\n\t\tif typeSet[\"permissions\"] && len(backup.Permissions) > 0 {\n\t\t\tcount, err := importPermissions(tx, backup.Permissions, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import permissions failed: %w\", err)\n\t\t\t}\n\t\t\tresult.PermissionsImported = count\n\t\t}\n\t\tif typeSet[\"configs\"] && len(backup.Configs) > 0 {\n\t\t\tcount, err := importConfigs(tx, backup.Configs, now)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"import configs failed: %w\", err)\n\t\t\t}\n\t\t\tresult.ConfigsImported = count\n\t\t}\n\t\treturn nil\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result, nil\n}\n\nfunc importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, u := range users {\n\t\titem := model.User{\n\t\t\tID: u.ID,\n\t\t\tUser: u.User,\n\t\t\tPwd: u.Pwd,\n\t\t\tRoleID: u.RoleID,\n\t\t\tExpTime: u.ExpTime,\n\t\t\tFlow: u.Flow,\n\t\t\tInFlow: u.InFlow,\n\t\t\tOutFlow: u.OutFlow,\n\t\t\tFlowResetTime: u.FlowResetTime,\n\t\t\tNum: u.Num,\n\t\t\tCreatedTime: u.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: u.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user\", \"pwd\", \"role_id\", \"exp_time\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"num\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, n := range nodes {\n\t\titem := model.Node{\n\t\t\tID: n.ID,\n\t\t\tName: n.Name,\n\t\t\tSecret: n.Secret,\n\t\t\tServerIP: n.ServerIP,\n\t\t\tServerIPV4: sql.NullString{String: n.ServerIPv4, Valid: true},\n\t\t\tServerIPV6: sql.NullString{String: n.ServerIPv6, Valid: true},\n\t\t\tPort: n.Port,\n\t\t\tInterfaceName: sql.NullString{String: n.InterfaceName, Valid: true},\n\t\t\tVersion: sql.NullString{String: n.Version, Valid: true},\n\t\t\tHTTP: n.HTTP,\n\t\t\tTLS: n.TLS,\n\t\t\tSocks: n.Socks,\n\t\t\tCreatedTime: n.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: n.Status,\n\t\t\tTCPListenAddr: n.TCPListenAddr,\n\t\t\tUDPListenAddr: n.UDPListenAddr,\n\t\t\tInx: n.Inx,\n\t\t\tIsRemote: n.IsRemote,\n\t\t\tRemoteURL: sql.NullString{String: n.RemoteURL, Valid: true},\n\t\t\tRemoteToken: sql.NullString{String: n.RemoteToken, Valid: true},\n\t\t\tRemoteConfig: sql.NullString{String: n.RemoteConfig, Valid: true},\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"secret\", \"server_ip\", \"server_ip_v4\", \"server_ip_v6\", \"port\", \"interface_name\", \"version\",\n\t\t\t\t\"http\", \"tls\", \"socks\", \"updated_time\", \"status\", \"tcp_listen_addr\", \"udp_listen_addr\",\n\t\t\t\t\"inx\", \"is_remote\", \"remote_url\", \"remote_token\", \"remote_config\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnels(tx *gorm.DB, tunnels []model.TunnelBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, t := range tunnels {\n\t\titem := model.Tunnel{\n\t\t\tID: t.ID,\n\t\t\tName: t.Name,\n\t\t\tTrafficRatio: t.TrafficRatio,\n\t\t\tType: t.Type,\n\t\t\tProtocol: t.Protocol,\n\t\t\tFlow: t.Flow,\n\t\t\tCreatedTime: t.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: t.Status,\n\t\t\tInIP: sql.NullString{String: t.InIP, Valid: true},\n\t\t\tInx: t.Inx,\n\t\t\tIPPreference: t.IPPreference,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"traffic_ratio\", \"type\", \"protocol\", \"flow\", \"updated_time\", \"status\", \"in_ip\", \"inx\", \"ip_preference\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, ct := range t.ChainTunnels {\n\t\t\tchainItem := model.ChainTunnel{\n\t\t\t\tID: ct.ID,\n\t\t\t\tTunnelID: ct.TunnelID,\n\t\t\t\tChainType: ct.ChainType,\n\t\t\t\tNodeID: ct.NodeID,\n\t\t\t\tPort: sql.NullInt64{Int64: int64(ct.Port), Valid: true},\n\t\t\t\tStrategy: sql.NullString{String: ct.Strategy, Valid: true},\n\t\t\t\tInx: sql.NullInt64{Int64: int64(ct.Inx), Valid: true},\n\t\t\t\tProtocol: sql.NullString{String: ct.Protocol, Valid: true},\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\t\"chain_type\", \"node_id\", \"port\", \"strategy\", \"inx\", \"protocol\",\n\t\t\t\t}),\n\t\t\t}).Create(&chainItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importForwards(tx *gorm.DB, forwards []model.ForwardBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, f := range forwards {\n\t\titem := model.Forward{\n\t\t\tID: f.ID,\n\t\t\tUserID: f.UserID,\n\t\t\tUserName: f.UserName,\n\t\t\tName: f.Name,\n\t\t\tTunnelID: f.TunnelID,\n\t\t\tRemoteAddr: f.RemoteAddr,\n\t\t\tStrategy: f.Strategy,\n\t\t\tInFlow: f.InFlow,\n\t\t\tOutFlow: f.OutFlow,\n\t\t\tCreatedTime: f.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: f.Status,\n\t\t\tInx: f.Inx,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"user_name\", \"name\", \"tunnel_id\", \"remote_addr\", \"strategy\",\n\t\t\t\t\"in_flow\", \"out_flow\", \"updated_time\", \"status\", \"inx\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif f.ForwardPorts != nil {\n\t\t\tif err := tx.Where(\"forward_id = ?\", f.ID).Delete(&model.ForwardPort{}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t\tfor _, fp := range *f.ForwardPorts {\n\t\t\t\tif err := tx.Create(&model.ForwardPort{ForwardID: f.ID, NodeID: fp.NodeID, Port: fp.Port}).Error; err != nil {\n\t\t\t\t\treturn count, err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserTunnels(tx *gorm.DB, userTunnels []model.UserTunnelBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, ut := range userTunnels {\n\t\titem := model.UserTunnel{\n\t\t\tID: ut.ID,\n\t\t\tUserID: ut.UserID,\n\t\t\tTunnelID: ut.TunnelID,\n\t\t\tSpeedID: sql.NullInt64{Int64: ut.SpeedID, Valid: ut.SpeedID > 0},\n\t\t\tNum: ut.Num,\n\t\t\tFlow: ut.Flow,\n\t\t\tInFlow: ut.InFlow,\n\t\t\tOutFlow: ut.OutFlow,\n\t\t\tFlowResetTime: ut.FlowResetTime,\n\t\t\tExpTime: ut.ExpTime,\n\t\t\tStatus: ut.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"user_id\", \"tunnel_id\", \"speed_id\", \"num\", \"flow\", \"in_flow\", \"out_flow\",\n\t\t\t\t\"flow_reset_time\", \"exp_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importSpeedLimits(tx *gorm.DB, speedLimits []model.SpeedLimitBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, sl := range speedLimits {\n\t\titem := model.SpeedLimit{\n\t\t\tID: sl.ID,\n\t\t\tName: sl.Name,\n\t\t\tSpeed: int(sl.Speed),\n\t\t\tTunnelID: sql.NullInt64{Int64: 0, Valid: false},\n\t\t\tTunnelName: sql.NullString{String: \"\", Valid: false},\n\t\t\tCreatedTime: sl.CreatedTime,\n\t\t\tUpdatedTime: sql.NullInt64{Int64: now, Valid: true},\n\t\t\tStatus: sl.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\n\t\t\t\t\"name\", \"speed\", \"tunnel_id\", \"tunnel_name\", \"updated_time\", \"status\",\n\t\t\t}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importTunnelGroups(tx *gorm.DB, tunnelGroups []model.TunnelGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, tg := range tunnelGroups {\n\t\titem := model.TunnelGroup{\n\t\t\tID: tg.ID,\n\t\t\tName: tg.Name,\n\t\t\tCreatedTime: tg.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: tg.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"tunnel_group_id = ?\", tg.ID).Delete(&model.TunnelGroupTunnel{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, tunnelID := range tg.Tunnels {\n\t\t\tif err := tx.Create(&model.TunnelGroupTunnel{TunnelGroupID: tg.ID, TunnelID: tunnelID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importUserGroups(tx *gorm.DB, userGroups []model.UserGroupBackup, now int64) (int, error) {\n\tcount := 0\n\tfor _, ug := range userGroups {\n\t\titem := model.UserGroup{\n\t\t\tID: ug.ID,\n\t\t\tName: ug.Name,\n\t\t\tCreatedTime: ug.CreatedTime,\n\t\t\tUpdatedTime: now,\n\t\t\tStatus: ug.Status,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"name\", \"updated_time\", \"status\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tif err := tx.Where(\"user_group_id = ?\", ug.ID).Delete(&model.UserGroupUser{}).Error; err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, userID := range ug.Users {\n\t\t\tif err := tx.Create(&model.UserGroupUser{UserGroupID: ug.ID, UserID: userID, CreatedTime: now}).Error; err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importPermissions(tx *gorm.DB, permissions []model.PermissionBackup, _ int64) (int, error) {\n\tcount := 0\n\tfor _, p := range permissions {\n\t\titem := model.GroupPermission{\n\t\t\tID: p.ID,\n\t\t\tUserGroupID: p.UserGroupID,\n\t\t\tTunnelGroupID: p.TunnelGroupID,\n\t\t\tCreatedTime: p.CreatedTime,\n\t\t}\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_group_id\", \"tunnel_group_id\"}),\n\t\t}).Create(&item).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tfor _, g := range p.Grants {\n\t\t\tgrantItem := model.GroupPermissionGrant{\n\t\t\t\tID: g.ID,\n\t\t\t\tUserGroupID: g.UserGroupID,\n\t\t\t\tTunnelGroupID: g.TunnelGroupID,\n\t\t\t\tUserTunnelID: g.UserTunnelID,\n\t\t\t\tCreatedTime: g.CreatedTime,\n\t\t\t\tCreatedByGroup: g.CreatedByGroup,\n\t\t\t}\n\t\t\terr = tx.Clauses(clause.OnConflict{\n\t\t\t\tColumns: []clause.Column{{Name: \"id\"}},\n\t\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"user_tunnel_id\", \"created_by_group\"}),\n\t\t\t}).Create(&grantItem).Error\n\t\t\tif err != nil {\n\t\t\t\treturn count, err\n\t\t\t}\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\nfunc importConfigs(tx *gorm.DB, configs map[string]string, now int64) (int, error) {\n\tcount := 0\n\tfor name, value := range configs {\n\t\terr := tx.Clauses(clause.OnConflict{\n\t\t\tColumns: []clause.Column{{Name: \"name\"}},\n\t\t\tDoUpdates: clause.AssignmentColumns([]string{\"value\", \"time\"}),\n\t\t}).Create(&model.ViteConfig{Name: name, Value: value, Time: now}).Error\n\t\tif err != nil {\n\t\t\treturn count, err\n\t\t}\n\t\tcount++\n\t}\n\treturn count, nil\n}\n\n// ─── Jobs Queries (background stats / expiry) ───────────────────────\n\nfunc (r *Repository) PurgeOldStatisticsFlows(cutoffMs int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Where(\"created_time < ?\", cutoffMs).Delete(&model.StatisticsFlow{}).Error\n}\n\nfunc (r *Repository) ListAllUserFlowSnapshots() ([]model.UserFlowSnapshot, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar users []model.User\n\terr := r.db.Order(\"id ASC\").Find(&users).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.UserFlowSnapshot, len(users))\n\tfor i, u := range users {\n\t\tout[i] = model.UserFlowSnapshot{UserID: u.ID, InFlow: u.InFlow, OutFlow: u.OutFlow}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) GetLastStatisticsFlowTotal(userID int64) (sql.NullInt64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn sql.NullInt64{}, errors.New(\"repository not initialized\")\n\t}\n\tvar sf model.StatisticsFlow\n\terr := r.db.Where(\"user_id = ?\", userID).Order(\"id DESC\").First(&sf).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn sql.NullInt64{}, nil\n\t}\n\tif err != nil {\n\t\treturn sql.NullInt64{}, err\n\t}\n\treturn sql.NullInt64{Int64: sf.TotalFlow, Valid: true}, nil\n}\n\nfunc (r *Repository) CreateStatisticsFlow(userID, flow, totalFlow int64, timeText string, createdTime int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Create(&model.StatisticsFlow{\n\t\tUserID: userID, Flow: flow, TotalFlow: totalFlow,\n\t\tTime: timeText, CreatedTime: createdTime,\n\t}).Error\n}\n\nfunc (r *Repository) ResetUserMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.User{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.User{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ResetUserTunnelMonthlyFlow(day int, lastDay int) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\tupdates := map[string]interface{}{\"in_flow\": 0, \"out_flow\": 0}\n\tif day == lastDay {\n\t\treturn r.db.Model(&model.UserTunnel{}).\n\t\t\tWhere(\"flow_reset_time != 0 AND (flow_reset_time = ? OR flow_reset_time > ?)\", day, lastDay).\n\t\t\tUpdates(updates).Error\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).\n\t\tWhere(\"flow_reset_time != 0 AND flow_reset_time = ?\", day).\n\t\tUpdates(updates).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ids []int64\n\terr := r.db.Model(&model.User{}).\n\t\tWhere(\"role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).\n\t\tPluck(\"id\", &ids).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ids, nil\n}\n\nfunc (r *Repository) DisableUser(userID int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.User{}).Where(\"id = ?\", userID).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredUserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar uts []model.UserTunnel\n\terr := r.db.Where(\"status = 1 AND exp_time > 0 AND exp_time < ?\", nowMs).Find(&uts).Error\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tout := make([]model.ExpiredUserTunnel, len(uts))\n\tfor i, ut := range uts {\n\t\tout[i] = model.ExpiredUserTunnel{ID: ut.ID, UserID: ut.UserID, TunnelID: ut.TunnelID}\n\t}\n\treturn out, nil\n}\n\nfunc (r *Repository) DisableUserTunnel(id int64) error {\n\tif r == nil || r.db == nil {\n\t\treturn errors.New(\"repository not initialized\")\n\t}\n\treturn r.db.Model(&model.UserTunnel{}).Where(\"id = ?\", id).Update(\"status\", 0).Error\n}\n\nfunc (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {\n\tif r == nil || r.db == nil {\n\t\treturn nil, errors.New(\"repository not initialized\")\n\t}\n\tvar ut model.UserTunnel\n\terr := r.db.Where(\"id = ?\", id).First(&ut).Error\n\tif errors.Is(err, gorm.ErrRecordNotFound) {\n\t\treturn nil, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &ut, nil\n}\n\n// ─── Migration ───────────────────────────────────────────────────────\n\nconst currentSchemaVersion = 4\n\nvar ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults\nvar migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType\nvar migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding\n\nfunc getSchemaVersion(db *gorm.DB) int {\n\tvar v model.SchemaVersion\n\tif err := db.First(&v).Error; err != nil {\n\t\tdb.Create(&model.SchemaVersion{Version: 0})\n\t\treturn 0\n\t}\n\treturn v.Version\n}\n\nfunc setSchemaVersion(db *gorm.DB, ver int) {\n\tdb.Model(&model.SchemaVersion{}).Where(\"1=1\").Update(\"version\", ver)\n}\n\nfunc migrateSchema(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif err := ensurePostgresIDDefaultsFn(db); err != nil {\n\t\treturn err\n\t}\n\n\tver := getSchemaVersion(db)\n\tif ver >= currentSchemaVersion {\n\t\treturn nil\n\t}\n\n\t// Normalize strategy columns\n\tnormalizeStrategy := func(modelRef interface{}, table, defaultValue string) error {\n\t\tresult := db.Model(modelRef).Where(\"strategy IS NULL\").Update(\"strategy\", defaultValue)\n\t\tif result.Error != nil {\n\t\t\tmsg := strings.ToLower(result.Error.Error())\n\t\t\tif strings.Contains(msg, \"no such table\") || (strings.Contains(msg, \"relation\") && strings.Contains(msg, \"does not exist\")) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"normalize %s.strategy: %w\", table, result.Error)\n\t\t}\n\t\treturn nil\n\t}\n\n\tif err := normalizeStrategy(&model.Forward{}, \"forward\", \"fifo\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.ChainTunnel{}, \"chain_tunnel\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\tif err := normalizeStrategy(&model.PeerShareRuntime{}, \"peer_share_runtime\", \"round\"); err != nil {\n\t\treturn err\n\t}\n\n\tif ver < 3 {\n\t\tif err := migrateViteConfigValueColumnTypeFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif ver < 4 {\n\t\tif err := migrateSpeedLimitTunnelBindingFn(db); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tsetSchemaVersion(db, currentSchemaVersion)\n\treturn nil\n}\n\nfunc migrateViteConfigValueColumnType(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.ViteConfig{}) {\n\t\treturn nil\n\t}\n\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\n\ttype columnRow struct {\n\t\tDataType string `gorm:\"column:data_type\"`\n\t}\n\n\tvar row columnRow\n\tif err := db.Raw(\n\t\t`SELECT data_type FROM information_schema.columns\n\t\t WHERE table_schema = current_schema()\n\t\t AND table_name = ?\n\t\t AND column_name = ?`,\n\t\t\"vite_config\", \"value\",\n\t).Scan(&row).Error; err != nil {\n\t\treturn fmt.Errorf(\"inspect vite_config.value type: %w\", err)\n\t}\n\n\tif strings.EqualFold(row.DataType, \"text\") {\n\t\treturn nil\n\t}\n\n\tif err := db.Exec(`ALTER TABLE \"vite_config\" ALTER COLUMN \"value\" TYPE TEXT`).Error; err != nil {\n\t\treturn fmt.Errorf(\"alter vite_config.value to text: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc migrateSpeedLimitTunnelBinding(db *gorm.DB) error {\n\tif db == nil {\n\t\treturn errors.New(\"nil db\")\n\t}\n\n\tif !db.Migrator().HasTable(&model.SpeedLimit{}) {\n\t\treturn nil\n\t}\n\n\tif err := db.Model(&model.SpeedLimit{}).\n\t\tWhere(\"tunnel_id IS NOT NULL OR tunnel_name IS NOT NULL\").\n\t\tUpdateColumns(map[string]interface{}{\n\t\t\t\"tunnel_id\": nil,\n\t\t\t\"tunnel_name\": nil,\n\t\t}).Error; err != nil {\n\t\treturn fmt.Errorf(\"clear speed_limit tunnel binding: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc ensurePostgresIDDefaults(db *gorm.DB) error {\n\tif db.Dialector.Name() != \"postgres\" {\n\t\treturn nil\n\t}\n\ttype idRow struct {\n\t\tTableSchema string\n\t\tTableName string\n\t}\n\tvar rows []idRow\n\terr := db.Table(\"information_schema.table_constraints AS tc\").\n\t\tSelect(\"c.table_schema, c.table_name\").\n\t\tJoins(\"JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema\").\n\t\tJoins(\"JOIN information_schema.columns AS c ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name\").\n\t\tWhere(\"tc.constraint_type = ?\", \"PRIMARY KEY\").\n\t\tWhere(\"kcu.column_name = ?\", \"id\").\n\t\tWhere(\"c.data_type IN ?\", []string{\"integer\", \"bigint\"}).\n\t\tWhere(\"c.is_identity = ?\", \"NO\").\n\t\tWhere(\"c.table_schema = current_schema()\").\n\t\tOrder(\"c.table_name ASC\").\n\t\tScan(&rows).Error\n\tif err != nil {\n\t\treturn fmt.Errorf(\"discover postgres id columns: %w\", err)\n\t}\n\n\tfor _, r := range rows {\n\t\tif err := ensurePostgresTableIDDefault(db, r.TableSchema, r.TableName); err != nil {\n\t\t\treturn fmt.Errorf(\"repair %s.%s id default: %w\", r.TableSchema, r.TableName, err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc ensurePostgresTableIDDefault(db *gorm.DB, schemaName, tableName string) error {\n\ttype defaultRow struct {\n\t\tColumnDefault sql.NullString `gorm:\"column:column_default\"`\n\t}\n\tvar row defaultRow\n\terr := db.Table(\"information_schema.columns\").\n\t\tSelect(\"column_default\").\n\t\tWhere(\"table_schema = ? AND table_name = ? AND column_name = 'id'\", schemaName, tableName).\n\t\tLimit(1).\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefaultExpr := row.ColumnDefault\n\n\thasNextvalDefault := defaultExpr.Valid && strings.Contains(strings.ToLower(defaultExpr.String), \"nextval(\")\n\tseqRef := \"\"\n\tif hasNextvalDefault {\n\t\tseqRef = extractNextvalRegclass(defaultExpr.String)\n\t}\n\n\tif !hasNextvalDefault || seqRef == \"\" {\n\t\tseqName := tableName + \"_id_seq\"\n\t\tif err := db.Exec(fmt.Sprintf(\"CREATE SEQUENCE IF NOT EXISTS %s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName))).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tseqRef = schemaName + \".\" + seqName\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER TABLE %s.%s ALTER COLUMN id SET DEFAULT nextval(%s::regclass)\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName), quoteSQLLiteral(seqRef),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err := db.Exec(fmt.Sprintf(\n\t\t\t\"ALTER SEQUENCE %s.%s OWNED BY %s.%s.id\",\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(seqName),\n\t\t\tquoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName),\n\t\t)).Error; err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn syncPostgresTableIDSequence(db, schemaName, tableName, seqRef)\n}\n\nfunc syncPostgresTableIDSequence(db *gorm.DB, schemaName, tableName, seqRef string) error {\n\ttype maxRow struct {\n\t\tMaxID int64 `gorm:\"column:max_id\"`\n\t}\n\tvar row maxRow\n\tqualifiedTable := fmt.Sprintf(\"%s.%s\", quoteSQLIdentifier(schemaName), quoteSQLIdentifier(tableName))\n\terr := db.Table(qualifiedTable).\n\t\tSelect(\"COALESCE(MAX(id), 0) AS max_id\").\n\t\tScan(&row).Error\n\tif err != nil {\n\t\treturn err\n\t}\n\tmaxID := row.MaxID\n\n\tsetVal := maxID\n\tisCalled := true\n\tif maxID <= 0 {\n\t\tsetVal = 1\n\t\tisCalled = false\n\t}\n\n\treturn db.Exec(`SELECT setval(?::regclass, ?, ?)`, seqRef, setVal, isCalled).Error\n}\n\nfunc extractNextvalRegclass(defaultExpr string) string {\n\tnextvalIdx := strings.Index(strings.ToLower(defaultExpr), \"nextval(\")\n\tif nextvalIdx < 0 {\n\t\treturn \"\"\n\t}\n\texpr := defaultExpr[nextvalIdx:]\n\tfirstQuote := strings.Index(expr, \"'\")\n\tif firstQuote < 0 {\n\t\treturn \"\"\n\t}\n\texpr = expr[firstQuote+1:]\n\tsecondQuote := strings.Index(expr, \"'\")\n\tif secondQuote < 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.TrimSpace(expr[:secondQuote])\n}\n\nfunc quoteSQLIdentifier(ident string) string {\n\treturn `\"` + strings.ReplaceAll(ident, `\"`, `\"\"`) + `\"`\n}\n\nfunc quoteSQLLiteral(value string) string {\n\treturn \"'\" + strings.ReplaceAll(value, \"'\", \"''\") + \"'\"\n}\n\n// ─── Helper Functions ────────────────────────────────────────────────\n\nfunc resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string, sql.NullInt64, error) {\n\tvar tunnelInIP sql.NullString\n\tdb.Model(&model.Tunnel{}).Select(\"in_ip\").Where(\"id = ?\", tunnelID).Limit(1).Scan(&tunnelInIP)\n\n\ttype fpRow struct {\n\t\tPort sql.NullInt64\n\t\tServerIP sql.NullString\n\t\tInIP sql.NullString\n\t}\n\tvar fpRows []fpRow\n\terr := db.Model(&model.ForwardPort{}).\n\t\tSelect(\"forward_port.port, node.server_ip, forward_port.in_ip\").\n\t\tJoins(\"LEFT JOIN node ON node.id = forward_port.node_id\").\n\t\tWhere(\"forward_port.forward_id = ?\", forwardID).\n\t\tOrder(\"forward_port.id ASC\").\n\t\tFind(&fpRows).Error\n\tif err != nil {\n\t\treturn \"\", sql.NullInt64{}, err\n\t}\n\n\tports := make([]int64, 0)\n\tentries := make([]string, 0)\n\tseenPorts := make(map[int64]struct{})\n\tseenPairs := make(map[string]struct{})\n\n\tvar tunnelFirstIP string\n\tif tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != \"\" {\n\t\ttunnelIPs := strings.Split(tunnelInIP.String, \",\")\n\t\tfor _, ip := range tunnelIPs {\n\t\t\tip = strings.TrimSpace(ip)\n\t\t\tif ip != \"\" {\n\t\t\t\ttunnelFirstIP = ip\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tfor _, row := range fpRows {\n\t\tif !row.Port.Valid {\n\t\t\tcontinue\n\t\t}\n\t\tif _, ok := seenPorts[row.Port.Int64]; !ok {\n\t\t\tseenPorts[row.Port.Int64] = struct{}{}\n\t\t\tports = append(ports, row.Port.Int64)\n\t\t}\n\n\t\tvar ip string\n\t\tif row.InIP.Valid && strings.TrimSpace(row.InIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.InIP.String)\n\t\t} else if tunnelFirstIP != \"\" {\n\t\t\tip = tunnelFirstIP\n\t\t} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != \"\" {\n\t\t\tip = strings.TrimSpace(row.ServerIP.String)\n\t\t}\n\n\t\tif ip != \"\" {\n\t\t\tpair := fmt.Sprintf(\"%s:%d\", ip, row.Port.Int64)\n\t\t\tif _, ok := seenPairs[pair]; !ok {\n\t\t\t\tseenPairs[pair] = struct{}{}\n\t\t\t\tentries = append(entries, pair)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(ports) == 0 {\n\t\treturn \"\", sql.NullInt64{}, nil\n\t}\n\n\tinPort := sql.NullInt64{Int64: ports[0], Valid: true}\n\n\treturn strings.Join(entries, \",\"), inPort, nil\n}\n\nfunc nullableString(v sql.NullString) interface{} {\n\tif v.Valid {\n\t\treturn v.String\n\t}\n\treturn nil\n}\n\nfunc nullableForwardIngress(v string) interface{} {\n\tv = strings.TrimSpace(v)\n\tif v == \"\" {\n\t\treturn nil\n\t}\n\treturn v\n}\n\nfunc nullableInt64(v sql.NullInt64) interface{} {\n\tif v.Valid {\n\t\treturn v.Int64\n\t}\n\treturn nil\n}\n\nfunc unixMilliNow() int64 {\n\treturn time.Now().UnixMilli()\n}\n\nfunc ensureParentDir(dbPath string) error {\n\tif dbPath == \"\" {\n\t\treturn fmt.Errorf(\"empty db path\")\n\t}\n\tdir := filepath.Dir(dbPath)\n\tif dir == \"\" || dir == \".\" {\n\t\treturn nil\n\t}\n\treturn osMkdirAll(dir)\n}\n\nvar osMkdirAll = func(path string) error {\n\treturn os.MkdirAll(path, 0o755)\n}\n\n// Suppress unused import warning for log\nvar _ = log.Printf\n",
|
||
"additions": 1,
|
||
"deletions": 1
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 383,
|
||
"character": 25
|
||
},
|
||
"end": {
|
||
"line": 383,
|
||
"character": 36
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 392,
|
||
"character": 44
|
||
},
|
||
"end": {
|
||
"line": 392,
|
||
"character": 55
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 400,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 400,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 410,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 410,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 415,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 415,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
]
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504085925,
|
||
"end": 1772504086121
|
||
}
|
||
},
|
||
"id": "prt_cb179ec650014olWzDrucAyj4Z",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1799c53001MVO7AWyDyJ0hMf"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111786,
|
||
"input": 18956,
|
||
"output": 1310,
|
||
"reasoning": 1065,
|
||
"cache": {
|
||
"read": 91520,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb179f66b00127pUpzKYAoKN4Q",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1799c53001MVO7AWyDyJ0hMf"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "20054b1c6fbac40ef2431da12f04f23c252c7c01",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go"
|
||
],
|
||
"id": "prt_cb179f6a5001RCL77lhS760KpO",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb1799c53001MVO7AWyDyJ0hMf"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504086186,
|
||
"completed": 1772504096441
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111049,
|
||
"input": 110748,
|
||
"output": 301,
|
||
"reasoning": 192,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb179f6aa001pYirK7poR1k4FR",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"id": "prt_cb17a168b0014Pit89ILWs7Uw5",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb179f6aa001pYirK7poR1k4FR"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Reviewing legacy column handling**",
|
||
"time": {
|
||
"start": 1772504094348,
|
||
"end": 1772504094867
|
||
},
|
||
"id": "prt_cb17a168c001ZUKwKqbKl0heSV",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb179f6aa001pYirK7poR1k4FR"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_mtDMhVGn5uzlCscHwHZTCppj",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./internal/store/repo ./tests/contract -run \"TestMigrateSchema|TestOpenMigratesLegacyNodeDualStackColumns\"",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Runs migration related repository and contract tests"
|
||
},
|
||
"output": "ok \tgo-backend/internal/store/repo\t0.569s\nok \tgo-backend/tests/contract\t0.679s\n",
|
||
"title": "Runs migration related repository and contract tests",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/internal/store/repo\t0.569s\nok \tgo-backend/tests/contract\t0.679s\n",
|
||
"exit": 0,
|
||
"description": "Runs migration related repository and contract tests",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504094768,
|
||
"end": 1772504096383
|
||
}
|
||
},
|
||
"id": "prt_cb17a171e001SZJiz8ghaRTjNI",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb179f6aa001pYirK7poR1k4FR"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111049,
|
||
"input": 110748,
|
||
"output": 301,
|
||
"reasoning": 192,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17a1e7f001I64unwkjVASMok",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb179f6aa001pYirK7poR1k4FR"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504096444,
|
||
"completed": 1772504111741
|
||
},
|
||
"parentID": "msg_cb17842b10010HDxnEyBULStV5",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111332,
|
||
"input": 110895,
|
||
"output": 437,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb17a1ebc0019u0k3SonvjZht7",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"id": "prt_cb17a3d17001NWbHgW3EvUHw3a",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17a1ebc0019u0k3SonvjZht7"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "你这个要求很对,我做了**全量排查(聚焦“旧 SQLite 升级缺列”同类问题)**,并且已经补了两个潜在同类坑。\n\n处理结果如下:\n\n- 发现根因模式:`autoMigrateAll` 对 SQLite 的 `node`/`tunnel` 表是跳过 `AutoMigrate` 的,完全依赖 `prepareSQLiteLegacyColumns` 手动补列;所以这里漏一个字段就会线上报 “no such column”。\n- 已修复并增强 `node` 旧库补列:\n - 已加:`ExtraIPs`(对应 `extra_ips`)\n - 额外补齐:`TCPListenAddr`、`UDPListenAddr`(避免更老版本库再出现同类缺列)\n - 文件:`go-backend/internal/store/repo/repository.go:263`\n- 已补强迁移回归测试,避免再回归:\n - `node` 侧新增断言 `extra_ips` 必须存在\n - `tunnel` 侧新增断言 `ip_preference` 必须存在(之前只测了 `inx`)\n - 文件:`go-backend/tests/contract/migration_contract_test.go:687`\n- 已跑验证:\n - `go test ./internal/store/repo ./tests/contract -run \"TestMigrateSchema|TestOpenMigratesLegacyNodeDualStackColumns\"` 全通过。\n\n结论: \n- 你刚遇到的 `extra_ips` 已修复。 \n- 我顺手把同路径下易漏的列也补齐并加了测试,**同类型 bug 风险已显著收敛**。\n\n你现在只需要重启后端一次,让迁移逻辑执行。 \n如果你愿意,我下一步可以再做一轮“跨表全量列对比(model vs 旧库补列逻辑)”并给你一份清单,确保这条链路彻底封口。",
|
||
"time": {
|
||
"start": 1772504111688,
|
||
"end": 1772504111688
|
||
},
|
||
"id": "prt_cb17a3d17002Z3Uy7f0f18oBSE",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17a1ebc0019u0k3SonvjZht7"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 111332,
|
||
"input": 110895,
|
||
"output": 437,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17a5a48001Mv67Hws8x3HMkb",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17a1ebc0019u0k3SonvjZht7"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "user",
|
||
"time": {
|
||
"created": 1772504146523
|
||
},
|
||
"summary": {
|
||
"diffs": [
|
||
{
|
||
"file": "go-backend/tests/contract/migration_contract_test.go",
|
||
"before": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"after": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-1.x.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create very legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create very legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\tfor _, required := range []string{\n\t\t\"server_ip_v4\",\n\t\t\"server_ip_v6\",\n\t\t\"extra_ips\",\n\t\t\"tcp_listen_addr\",\n\t\t\"udp_listen_addr\",\n\t\t\"inx\",\n\t\t\"is_remote\",\n\t\t\"remote_url\",\n\t\t\"remote_token\",\n\t\t\"remote_config\",\n\t} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"additions": 90,
|
||
"deletions": 0,
|
||
"status": "modified"
|
||
}
|
||
]
|
||
},
|
||
"agent": "build",
|
||
"model": {
|
||
"providerID": "api",
|
||
"modelID": "gpt-5.3-codex"
|
||
},
|
||
"variant": "xhigh",
|
||
"id": "msg_cb17ae254001MY3UZJ0wflUc7n",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "text",
|
||
"text": "再做一轮“跨表全量列对比(model vs 旧库补列逻辑)",
|
||
"id": "prt_cb17ae254002EHVNyaQM8pKM2T",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae254001MY3UZJ0wflUc7n"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504146544,
|
||
"completed": 1772504178779
|
||
},
|
||
"parentID": "msg_cb17ae254001MY3UZJ0wflUc7n",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 113408,
|
||
"input": 111361,
|
||
"output": 2047,
|
||
"reasoning": 939,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb17ae270001KWrHGCdq3xm8AB",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"id": "prt_cb17b2fdc001HwROVKLxJ56FMR",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae270001KWrHGCdq3xm8AB"
|
||
},
|
||
{
|
||
"type": "reasoning",
|
||
"text": "**Planning legacy schema coverage test**",
|
||
"time": {
|
||
"start": 1772504166365,
|
||
"end": 1772504178613
|
||
},
|
||
"id": "prt_cb17b2fdd001IPdw158UoeydMV",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae270001KWrHGCdq3xm8AB"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_sFPhmWS081Jq89tbs8BB2bsF",
|
||
"tool": "apply_patch",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"patchText": "*** Begin Patch\n*** Update File: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@\n func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n@@\n }\n+\n+func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {\n+\tdbPath := filepath.Join(t.TempDir(), \"legacy-1.x.db\")\n+\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n+\t}\n+\n+\tt.Cleanup(func() {\n+\t\t_ = legacyDB.Close()\n+\t})\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS node (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\tsecret VARCHAR(100) NOT NULL,\n+\t\t\tserver_ip VARCHAR(100) NOT NULL,\n+\t\t\tport TEXT NOT NULL,\n+\t\t\tinterface_name VARCHAR(200),\n+\t\t\tversion VARCHAR(100),\n+\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n+\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n+\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER,\n+\t\t\tstatus INTEGER NOT NULL\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy node table: %v\", err)\n+\t}\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS tunnel (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n+\t\t\ttype INTEGER NOT NULL,\n+\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n+\t\t\tflow INTEGER NOT NULL,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER NOT NULL,\n+\t\t\tstatus INTEGER NOT NULL,\n+\t\t\tin_ip TEXT\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy tunnel table: %v\", err)\n+\t}\n+\n+\tnow := time.Now().UnixMilli()\n+\tif _, err := legacyDB.Exec(`\n+\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)\n+\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1); err != nil {\n+\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n+\t}\n+\n+\tr, err := repo.Open(dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n+\t}\n+\tt.Cleanup(func() {\n+\t\t_ = r.Close()\n+\t})\n+\n+\tcolumns := readTableColumns(t, r.DB(), \"node\")\n+\tfor _, required := range []string{\n+\t\t\"server_ip_v4\",\n+\t\t\"server_ip_v6\",\n+\t\t\"extra_ips\",\n+\t\t\"tcp_listen_addr\",\n+\t\t\"udp_listen_addr\",\n+\t\t\"inx\",\n+\t\t\"is_remote\",\n+\t\t\"remote_url\",\n+\t\t\"remote_token\",\n+\t\t\"remote_config\",\n+\t} {\n+\t\tif !columns[required] {\n+\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+\n+\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+}\n \n func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n*** End Patch"
|
||
},
|
||
"output": "Success. Updated the following files:\nM go-backend/tests/contract/migration_contract_test.go",
|
||
"title": "Success. Updated the following files:\nM go-backend/tests/contract/migration_contract_test.go",
|
||
"metadata": {
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@ -697,8 +697,98 @@\n \t\t}\n \t}\n }\n \n+func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {\n+\tdbPath := filepath.Join(t.TempDir(), \"legacy-1.x.db\")\n+\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n+\t}\n+\n+\tt.Cleanup(func() {\n+\t\t_ = legacyDB.Close()\n+\t})\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS node (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\tsecret VARCHAR(100) NOT NULL,\n+\t\t\tserver_ip VARCHAR(100) NOT NULL,\n+\t\t\tport TEXT NOT NULL,\n+\t\t\tinterface_name VARCHAR(200),\n+\t\t\tversion VARCHAR(100),\n+\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n+\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n+\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER,\n+\t\t\tstatus INTEGER NOT NULL\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy node table: %v\", err)\n+\t}\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS tunnel (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n+\t\t\ttype INTEGER NOT NULL,\n+\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n+\t\t\tflow INTEGER NOT NULL,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER NOT NULL,\n+\t\t\tstatus INTEGER NOT NULL,\n+\t\t\tin_ip TEXT\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy tunnel table: %v\", err)\n+\t}\n+\n+\tnow := time.Now().UnixMilli()\n+\tif _, err := legacyDB.Exec(`\n+\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)\n+\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1); err != nil {\n+\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n+\t}\n+\n+\tr, err := repo.Open(dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n+\t}\n+\tt.Cleanup(func() {\n+\t\t_ = r.Close()\n+\t})\n+\n+\tcolumns := readTableColumns(t, r.DB(), \"node\")\n+\tfor _, required := range []string{\n+\t\t\"server_ip_v4\",\n+\t\t\"server_ip_v6\",\n+\t\t\"extra_ips\",\n+\t\t\"tcp_listen_addr\",\n+\t\t\"udp_listen_addr\",\n+\t\t\"inx\",\n+\t\t\"is_remote\",\n+\t\t\"remote_url\",\n+\t\t\"remote_token\",\n+\t\t\"remote_config\",\n+\t} {\n+\t\tif !columns[required] {\n+\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+\n+\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+}\n+\n func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n \tt.Helper()\n \n \tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\n",
|
||
"files": [
|
||
{
|
||
"filePath": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go",
|
||
"relativePath": "go-backend/tests/contract/migration_contract_test.go",
|
||
"type": "update",
|
||
"diff": "Index: /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n===================================================================\n--- /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n+++ /Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go\n@@ -697,8 +697,98 @@\n \t\t}\n \t}\n }\n \n+func TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {\n+\tdbPath := filepath.Join(t.TempDir(), \"legacy-1.x.db\")\n+\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n+\t}\n+\n+\tt.Cleanup(func() {\n+\t\t_ = legacyDB.Close()\n+\t})\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS node (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\tsecret VARCHAR(100) NOT NULL,\n+\t\t\tserver_ip VARCHAR(100) NOT NULL,\n+\t\t\tport TEXT NOT NULL,\n+\t\t\tinterface_name VARCHAR(200),\n+\t\t\tversion VARCHAR(100),\n+\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n+\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n+\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER,\n+\t\t\tstatus INTEGER NOT NULL\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy node table: %v\", err)\n+\t}\n+\n+\tif _, err := legacyDB.Exec(`\n+\t\tCREATE TABLE IF NOT EXISTS tunnel (\n+\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n+\t\t\tname VARCHAR(100) NOT NULL,\n+\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n+\t\t\ttype INTEGER NOT NULL,\n+\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n+\t\t\tflow INTEGER NOT NULL,\n+\t\t\tcreated_time INTEGER NOT NULL,\n+\t\t\tupdated_time INTEGER NOT NULL,\n+\t\t\tstatus INTEGER NOT NULL,\n+\t\t\tin_ip TEXT\n+\t\t)\n+\t`); err != nil {\n+\t\tt.Fatalf(\"create very legacy tunnel table: %v\", err)\n+\t}\n+\n+\tnow := time.Now().UnixMilli()\n+\tif _, err := legacyDB.Exec(`\n+\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)\n+\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n+\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1); err != nil {\n+\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n+\t}\n+\n+\tr, err := repo.Open(dbPath)\n+\tif err != nil {\n+\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n+\t}\n+\tt.Cleanup(func() {\n+\t\t_ = r.Close()\n+\t})\n+\n+\tcolumns := readTableColumns(t, r.DB(), \"node\")\n+\tfor _, required := range []string{\n+\t\t\"server_ip_v4\",\n+\t\t\"server_ip_v6\",\n+\t\t\"extra_ips\",\n+\t\t\"tcp_listen_addr\",\n+\t\t\"udp_listen_addr\",\n+\t\t\"inx\",\n+\t\t\"is_remote\",\n+\t\t\"remote_url\",\n+\t\t\"remote_token\",\n+\t\t\"remote_config\",\n+\t} {\n+\t\tif !columns[required] {\n+\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+\n+\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n+\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n+\t\tif !tunnelColumns[required] {\n+\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n+\t\t}\n+\t}\n+}\n+\n func readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n \tt.Helper()\n \n \tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n",
|
||
"before": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"after": "package contract_test\n\nimport (\n\t\"bytes\"\n\t\"database/sql\"\n\t\"encoding/json\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"go-backend/internal/auth\"\n\thttpserver \"go-backend/internal/http\"\n\t\"go-backend/internal/http/handler\"\n\t\"go-backend/internal/http/response\"\n\t\"go-backend/internal/store/repo\"\n\n\t\"gorm.io/gorm\"\n)\n\nfunc TestCaptchaVerifyLoginContract(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\tverifiedToken := \"\"\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"captcha_enabled\", \"true\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"enable captcha: %v\", err)\n\t}\n\n\tt.Run(\"login allowed when cloudflare keys are missing\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha verify remains compatible without cloudflare secret\", func(t *testing.T) {\n\t\tverifyReq := httptest.NewRequest(http.MethodPost, \"/api/v1/captcha/verify\", bytes.NewBufferString(`{\"id\":\"captcha-token-1\",\"data\":\"ok\"}`))\n\t\tverifyReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tverifyResp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(verifyResp, verifyReq)\n\n\t\tvar verifyOut struct {\n\t\t\tSuccess bool `json:\"success\"`\n\t\t\tData struct {\n\t\t\t\tValidToken string `json:\"validToken\"`\n\t\t\t} `json:\"data\"`\n\t\t}\n\t\tif err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {\n\t\t\tt.Fatalf(\"decode captcha verify response: %v\", err)\n\t\t}\n\t\tif !verifyOut.Success || verifyOut.Data.ValidToken != \"captcha-token-1\" {\n\t\t\tt.Fatalf(\"unexpected captcha verify payload: success=%v token=%q\", verifyOut.Success, verifyOut.Data.ValidToken)\n\t\t}\n\n\t\tverifiedToken = verifyOut.Data.ValidToken\n\t})\n\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_site_key\", \"test-site-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare site key: %v\", err)\n\t}\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, \"cloudflare_secret_key\", \"test-secret-key\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"set cloudflare secret key: %v\", err)\n\t}\n\n\tt.Run(\"login denied without verified captcha token\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"验证码校验失败\")\n\t})\n\n\tt.Run(\"whmcs api client bypasses captcha\", func(t *testing.T) {\n\t\tbody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"\"}`)\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", body)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treq.Header.Set(\"X-FLVX-API-Client\", \"whmcs\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCode(t, resp, 0)\n\t})\n\n\tt.Run(\"captcha token is one-time and consumed by login\", func(t *testing.T) {\n\t\tif strings.TrimSpace(verifiedToken) == \"\" {\n\t\t\tt.Fatalf(\"expected verified token from compatibility captcha verify\")\n\t\t}\n\n\t\tloginBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\tloginReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", loginBody)\n\t\tloginReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tloginResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(loginResp, loginReq)\n\t\tassertCode(t, loginResp, 0)\n\n\t\treplayBody := bytes.NewBufferString(`{\"username\":\"admin_user\",\"password\":\"admin_user\",\"captchaId\":\"` + verifiedToken + `\"}`)\n\t\treplayReq := httptest.NewRequest(http.MethodPost, \"/api/v1/user/login\", replayBody)\n\t\treplayReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\treplayResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(replayResp, replayReq)\n\t\tassertCodeMsg(t, replayResp, -1, \"验证码校验失败\")\n\t})\n}\n\nfunc TestOpenAPISubStoreContracts(t *testing.T) {\n\trouter, r := setupContractRouter(t, \"contract-jwt-secret\")\n\n\tconst tunnelFlowGB = int64(500)\n\tconst tunnelInFlow = int64(123)\n\tconst tunnelOutFlow = int64(456)\n\tconst tunnelExpTimeMs = int64(2727251700000)\n\n\tnow := time.Now().UnixMilli()\n\tif err := r.DB().Exec(`INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t\"contract-tunnel\", 1.0, 1, \"tls\", 1, now, now, 1, nil, 0).Error; err != nil {\n\t\tt.Fatalf(\"insert tunnel: %v\", err)\n\t}\n\ttunnelID := mustLastInsertID(t, r, \"contract-tunnel\")\n\tif err := r.DB().Exec(`INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)`,\n\t\t1, tunnelID, 99999, tunnelFlowGB, tunnelInFlow, tunnelOutFlow, 1, tunnelExpTimeMs, 1).Error; err != nil {\n\t\tt.Fatalf(\"insert user_tunnel: %v\", err)\n\t}\n\n\tt.Run(\"default user subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=0; download=0; total=107373108658176; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t\tif !strings.Contains(resp.Header().Get(\"Content-Type\"), \"text/plain\") {\n\t\t\tt.Fatalf(\"expected text/plain content type, got %q\", resp.Header().Get(\"Content-Type\"))\n\t\t}\n\t})\n\n\tt.Run(\"tunnel scoped subscription payload\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=\"+strconv.FormatInt(tunnelID, 10), nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tbody, err := io.ReadAll(resp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read body: %v\", err)\n\t\t}\n\n\t\texpected := \"upload=123; download=456; total=536870912000; expire=2727251700\"\n\t\tif string(body) != expected {\n\t\t\tt.Fatalf(\"expected body %q, got %q\", expected, string(body))\n\t\t}\n\t\tif got := resp.Header().Get(\"subscription-userinfo\"); got != expected {\n\t\t\tt.Fatalf(\"expected subscription-userinfo %q, got %q\", expected, got)\n\t\t}\n\t})\n\n\tt.Run(\"invalid credentials returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=wrong\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"鉴权失败\")\n\t})\n\n\tt.Run(\"missing tunnel returns contract error\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodGet, \"/api/v1/open_api/sub_store?user=admin_user&pwd=admin_user&tunnel=999999\", nil)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tassertCodeMsg(t, resp, -1, \"隧道不存在\")\n\t})\n}\n\nfunc TestSpeedLimitTunnelsRouteRemoved(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, _ := setupContractRouter(t, secret)\n\n\ttoken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate token: %v\", err)\n\t}\n\n\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/speed-limit/tunnels\", nil)\n\treq.Header.Set(\"Authorization\", token)\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\n\tif resp.Code != http.StatusNotFound {\n\t\tt.Fatalf(\"expected status 404 after route removal, got %d\", resp.Code)\n\t}\n}\n\nfunc TestBackupExportImportRestoreContracts(t *testing.T) {\n\tsecret := \"contract-jwt-secret\"\n\trouter, r := setupContractRouter(t, secret)\n\n\tadminToken, err := auth.GenerateToken(1, \"admin_user\", 0, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate admin token: %v\", err)\n\t}\n\tuserToken, err := auth.GenerateToken(2, \"normal_user\", 1, secret)\n\tif err != nil {\n\t\tt.Fatalf(\"generate user token: %v\", err)\n\t}\n\n\tkey := \"backup_contract_key\"\n\tif err := r.DB().Exec(`\n\t\tINSERT INTO vite_config(name, value, time)\n\t\tVALUES(?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time\n\t`, key, \"v1\", time.Now().UnixMilli()).Error; err != nil {\n\t\tt.Fatalf(\"seed config for backup contract: %v\", err)\n\t}\n\n\tt.Run(\"non-admin is blocked on backup export\", func(t *testing.T) {\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", nil)\n\t\treq.Header.Set(\"Authorization\", userToken)\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tassertCodeMsg(t, resp, 403, \"权限不足,仅管理员可操作\")\n\t})\n\n\tt.Run(\"standard and duplicate export routes both work\", func(t *testing.T) {\n\t\tpayloadA := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tif len(payloadA.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs, got none\")\n\t\t}\n\t\tif _, ok := payloadA.Configs[key]; !ok {\n\t\t\tt.Fatalf(\"expected %q in exported configs\", key)\n\t\t}\n\n\t\tpayloadB := exportBackupPayload(t, router, \"/api/v1/api/v1/backup/export\", adminToken)\n\t\tif len(payloadB.Configs) == 0 {\n\t\t\tt.Fatalf(\"expected exported configs from duplicate-prefix route, got none\")\n\t\t}\n\t})\n\n\tt.Run(\"backup import applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v2\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal import payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query imported config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v2\" {\n\t\t\tt.Fatalf(\"expected imported config value v2, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup restore alias applies exported data\", func(t *testing.T) {\n\t\tpayload := exportBackupPayload(t, router, \"/api/v1/backup/export\", adminToken)\n\t\tpayload.Configs[key] = \"v3\"\n\t\traw, err := json.Marshal(backupImportPayload{Types: []string{\"configs\"}, backupExportPayload: payload})\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal restore payload: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/restore\", bytes.NewReader(raw))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\n\t\trouter.ServeHTTP(resp, req)\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(resp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode restore response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected restore code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tcfg, err := r.GetConfigByName(key)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"query restored config: %v\", err)\n\t\t}\n\t\tif cfg == nil || cfg.Value != \"v3\" {\n\t\t\tt.Fatalf(\"expected restored config value v3, got %+v\", cfg)\n\t\t}\n\t})\n\n\tt.Run(\"backup export and import preserve forward ports\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"backup-forward-tunnel\", 1.0, 1, \"tls\", 0, now, now, 1, \"\", 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for forward backup: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"backup-forward-tunnel\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, 1, \"admin_user\", \"backup-forward\", tunnelID, \"127.0.0.1:9000\", \"fifo\", 0, 0, now, now, 1, 88).Error; err != nil {\n\t\t\tt.Fatalf(\"seed forward for backup: %v\", err)\n\t\t}\n\t\tforwardID := mustLastInsertID(t, r, \"backup-forward\")\n\n\t\texpected := map[int64]int{\n\t\t\t2001: 21001,\n\t\t\t2002: 21002,\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {\n\t\t\t\tt.Fatalf(\"seed forward_port %d:%d: %v\", nodeID, port, err)\n\t\t\t}\n\t\t}\n\n\t\texportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"forwards\"]}`))\n\t\texportReq.Header.Set(\"Authorization\", adminToken)\n\t\texportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\texportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(exportResp, exportReq)\n\n\t\tif exportResp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected export status 200, got %d\", exportResp.Code)\n\t\t}\n\n\t\texportBody, err := io.ReadAll(exportResp.Body)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"read forwards backup body: %v\", err)\n\t\t}\n\n\t\tvar payload map[string]interface{}\n\t\tif err := json.Unmarshal(exportBody, &payload); err != nil {\n\t\t\tt.Fatalf(\"decode forwards backup payload: %v\", err)\n\t\t}\n\t\tversion, _ := payload[\"version\"].(string)\n\t\tif strings.TrimSpace(version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, body=%s\", string(exportBody))\n\t\t}\n\n\t\tforwardsRaw, ok := payload[\"forwards\"].([]interface{})\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected forwards array in payload, body=%s\", string(exportBody))\n\t\t}\n\n\t\tfoundForward := false\n\t\tfoundPorts := map[int64]int{}\n\t\tfor _, item := range forwardsRaw {\n\t\t\tforwardMap, ok := item.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tidValue, ok := forwardMap[\"id\"].(float64)\n\t\t\tif !ok || int64(idValue) != forwardID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfoundForward = true\n\n\t\t\tportsRaw, ok := forwardMap[\"forwardPorts\"].([]interface{})\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected forwardPorts for forward %d in payload\", forwardID)\n\t\t\t}\n\t\t\tfor _, p := range portsRaw {\n\t\t\t\tportMap, ok := p.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tnodeID, nodeOK := portMap[\"nodeId\"].(float64)\n\t\t\t\tport, portOK := portMap[\"port\"].(float64)\n\t\t\t\tif nodeOK && portOK {\n\t\t\t\t\tfoundPorts[int64(nodeID)] = int(port)\n\t\t\t\t}\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\n\t\tif !foundForward {\n\t\t\tt.Fatalf(\"expected forward %d in exported forwards payload\", forwardID)\n\t\t}\n\t\tif len(foundPorts) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d exported forward ports, got %d\", len(expected), len(foundPorts))\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := foundPorts[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected exported forward port node=%d port=%d, got %v\", nodeID, port, foundPorts)\n\t\t\t}\n\t\t}\n\n\t\tif err := r.DB().Exec(`DELETE FROM forward_port WHERE forward_id = ?`, forwardID).Error; err != nil {\n\t\t\tt.Fatalf(\"clear forward_port before import: %v\", err)\n\t\t}\n\t\tif err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, 9999, 39999).Error; err != nil {\n\t\t\tt.Fatalf(\"seed wrong forward_port before import: %v\", err)\n\t\t}\n\n\t\tpayload[\"types\"] = []string{\"forwards\"}\n\t\timportBody, err := json.Marshal(payload)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"marshal forwards import payload: %v\", err)\n\t\t}\n\n\t\timportReq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/import\", bytes.NewReader(importBody))\n\t\timportReq.Header.Set(\"Authorization\", adminToken)\n\t\timportReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\timportResp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(importResp, importReq)\n\n\t\tvar out response.R\n\t\tif err := json.NewDecoder(importResp.Body).Decode(&out); err != nil {\n\t\t\tt.Fatalf(\"decode forwards import response: %v\", err)\n\t\t}\n\t\tif out.Code != 0 {\n\t\t\tt.Fatalf(\"expected forwards import code 0, got %d (%s)\", out.Code, out.Msg)\n\t\t}\n\n\t\tafter := mustQueryNodePorts(t, r, `SELECT node_id, port FROM forward_port WHERE forward_id = ? ORDER BY id ASC`, forwardID)\n\n\t\tif len(after) != len(expected) {\n\t\t\tt.Fatalf(\"expected %d forward ports after import, got %d (%v)\", len(expected), len(after), after)\n\t\t}\n\t\tfor nodeID, port := range expected {\n\t\t\tif got, ok := after[nodeID]; !ok || got != port {\n\t\t\t\tt.Fatalf(\"expected forward_port node=%d port=%d after import, got %v\", nodeID, port, after)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"backup export tolerates nullable legacy tunnel chain fields\", func(t *testing.T) {\n\t\tnow := time.Now().UnixMilli()\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\t`, \"legacy-null-chain\", 1.0, 1, \"tls\", 1000, now, now, 1, nil, 1).Error; err != nil {\n\t\t\tt.Fatalf(\"seed tunnel for nullable chain export: %v\", err)\n\t\t}\n\t\ttunnelID := mustLastInsertID(t, r, \"legacy-null-chain\")\n\n\t\tif err := r.DB().Exec(`\n\t\t\tINSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)\n\t\t\tVALUES(?, ?, ?, ?, ?, ?, ?)\n\t\t`, tunnelID, \"1\", 1, nil, nil, nil, nil).Error; err != nil {\n\t\t\tt.Fatalf(\"seed nullable chain_tunnel row: %v\", err)\n\t\t}\n\n\t\treq := httptest.NewRequest(http.MethodPost, \"/api/v1/backup/export\", bytes.NewBufferString(`{\"types\":[\"tunnels\"]}`))\n\t\treq.Header.Set(\"Authorization\", adminToken)\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t\tresp := httptest.NewRecorder()\n\t\trouter.ServeHTTP(resp, req)\n\n\t\tif resp.Code != http.StatusOK {\n\t\t\tt.Fatalf(\"expected status 200, got %d\", resp.Code)\n\t\t}\n\n\t\tvar payload struct {\n\t\t\tVersion string `json:\"version\"`\n\t\t\tTunnels []struct {\n\t\t\t\tID int64 `json:\"id\"`\n\t\t\t\tChainTunnels []struct {\n\t\t\t\t\tInx int `json:\"inx\"`\n\t\t\t\t\tStrategy string `json:\"strategy\"`\n\t\t\t\t\tProtocol string `json:\"protocol\"`\n\t\t\t\t} `json:\"chainTunnels\"`\n\t\t\t} `json:\"tunnels\"`\n\t\t}\n\t\tif err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {\n\t\t\tt.Fatalf(\"decode tunnels backup payload: %v\", err)\n\t\t}\n\t\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\t\tt.Fatalf(\"expected backup payload version, got empty\")\n\t\t}\n\n\t\tfound := false\n\t\tfor _, tunnel := range payload.Tunnels {\n\t\t\tif tunnel.ID != tunnelID {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif len(tunnel.ChainTunnels) != 1 {\n\t\t\t\tt.Fatalf(\"expected one chain tunnel for seeded tunnel %d, got %d\", tunnelID, len(tunnel.ChainTunnels))\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Inx != 0 {\n\t\t\t\tt.Fatalf(\"expected nullable chain inx to export as 0, got %d\", tunnel.ChainTunnels[0].Inx)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Strategy != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain strategy to export as empty string, got %q\", tunnel.ChainTunnels[0].Strategy)\n\t\t\t}\n\t\t\tif tunnel.ChainTunnels[0].Protocol != \"\" {\n\t\t\t\tt.Fatalf(\"expected nullable chain protocol to export as empty string, got %q\", tunnel.ChainTunnels[0].Protocol)\n\t\t\t}\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t\tif !found {\n\t\t\tt.Fatalf(\"expected seeded tunnel %d in backup export\", tunnelID)\n\t\t}\n\t})\n}\n\ntype backupExportPayload struct {\n\tVersion string `json:\"version\"`\n\tExportedAt int64 `json:\"exportedAt\"`\n\tConfigs map[string]string `json:\"configs\"`\n}\n\ntype backupImportPayload struct {\n\tTypes []string `json:\"types\"`\n\tbackupExportPayload\n}\n\nfunc exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {\n\tt.Helper()\n\treq := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{\"types\":[\"configs\"]}`))\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\tresp := httptest.NewRecorder()\n\n\trouter.ServeHTTP(resp, req)\n\tif resp.Code != http.StatusOK {\n\t\tt.Fatalf(\"expected status 200 on %s, got %d\", path, resp.Code)\n\t}\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\tt.Fatalf(\"read backup payload from %s: %v\", path, err)\n\t}\n\n\tvar payload backupExportPayload\n\tif err := json.Unmarshal(body, &payload); err != nil {\n\t\tt.Fatalf(\"decode backup payload from %s: %v\", path, err)\n\t}\n\tif strings.TrimSpace(payload.Version) == \"\" {\n\t\tvar out response.R\n\t\tif err := json.Unmarshal(body, &out); err == nil {\n\t\t\tt.Fatalf(\"expected backup payload on %s, got envelope code=%d msg=%q\", path, out.Code, out.Msg)\n\t\t}\n\t\tt.Fatalf(\"expected non-empty backup payload version on %s, body=%s\", path, string(body))\n\t}\n\tif payload.Configs == nil {\n\t\tt.Fatalf(\"expected configs map in backup payload on %s\", path)\n\t}\n\treturn payload\n}\n\nfunc setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *repo.Repository) {\n\tt.Helper()\n\tdbPath := filepath.Join(t.TempDir(), \"contract.db\")\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\th := handler.New(r, jwtSecret)\n\treturn httpserver.NewRouter(h, jwtSecret), r\n}\n\nfunc TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-2.0.7-beta.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\ttcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',\n\t\t\tudp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1, \"[::]\", \"[::]\"); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tnodes, err := r.ListNodes()\n\tif err != nil {\n\t\tt.Fatalf(\"list nodes after migration: %v\", err)\n\t}\n\tif len(nodes) != 1 {\n\t\tt.Fatalf(\"expected 1 node after migration, got %d\", len(nodes))\n\t}\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\n\tfor _, required := range []string{\"server_ip_v4\", \"server_ip_v6\", \"inx\", \"extra_ips\"} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc TestOpenMigratesVeryLegacyNodeAndTunnelColumns(t *testing.T) {\n\tdbPath := filepath.Join(t.TempDir(), \"legacy-1.x.db\")\n\tlegacyDB, err := sql.Open(\"sqlite\", dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open legacy sqlite: %v\", err)\n\t}\n\n\tt.Cleanup(func() {\n\t\t_ = legacyDB.Close()\n\t})\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS node (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\tsecret VARCHAR(100) NOT NULL,\n\t\t\tserver_ip VARCHAR(100) NOT NULL,\n\t\t\tport TEXT NOT NULL,\n\t\t\tinterface_name VARCHAR(200),\n\t\t\tversion VARCHAR(100),\n\t\t\thttp INTEGER NOT NULL DEFAULT 0,\n\t\t\ttls INTEGER NOT NULL DEFAULT 0,\n\t\t\tsocks INTEGER NOT NULL DEFAULT 0,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER,\n\t\t\tstatus INTEGER NOT NULL\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create very legacy node table: %v\", err)\n\t}\n\n\tif _, err := legacyDB.Exec(`\n\t\tCREATE TABLE IF NOT EXISTS tunnel (\n\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\tname VARCHAR(100) NOT NULL,\n\t\t\ttraffic_ratio REAL NOT NULL DEFAULT 1.0,\n\t\t\ttype INTEGER NOT NULL,\n\t\t\tprotocol VARCHAR(10) NOT NULL DEFAULT 'tls',\n\t\t\tflow INTEGER NOT NULL,\n\t\t\tcreated_time INTEGER NOT NULL,\n\t\t\tupdated_time INTEGER NOT NULL,\n\t\t\tstatus INTEGER NOT NULL,\n\t\t\tin_ip TEXT\n\t\t)\n\t`); err != nil {\n\t\tt.Fatalf(\"create very legacy tunnel table: %v\", err)\n\t}\n\n\tnow := time.Now().UnixMilli()\n\tif _, err := legacyDB.Exec(`\n\t\tINSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status)\n\t\tVALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t`, \"legacy-node\", \"legacy-secret\", \"10.10.0.1\", \"10000-10010\", \"eth0\", \"v-old\", 1, 1, 1, now, now, 1); err != nil {\n\t\tt.Fatalf(\"seed legacy node row: %v\", err)\n\t}\n\n\tr, err := repo.Open(dbPath)\n\tif err != nil {\n\t\tt.Fatalf(\"open migrated sqlite: %v\", err)\n\t}\n\tt.Cleanup(func() {\n\t\t_ = r.Close()\n\t})\n\n\tcolumns := readTableColumns(t, r.DB(), \"node\")\n\tfor _, required := range []string{\n\t\t\"server_ip_v4\",\n\t\t\"server_ip_v6\",\n\t\t\"extra_ips\",\n\t\t\"tcp_listen_addr\",\n\t\t\"udp_listen_addr\",\n\t\t\"inx\",\n\t\t\"is_remote\",\n\t\t\"remote_url\",\n\t\t\"remote_token\",\n\t\t\"remote_config\",\n\t} {\n\t\tif !columns[required] {\n\t\t\tt.Fatalf(\"expected node column %q to exist after migration\", required)\n\t\t}\n\t}\n\n\ttunnelColumns := readTableColumns(t, r.DB(), \"tunnel\")\n\tfor _, required := range []string{\"inx\", \"ip_preference\"} {\n\t\tif !tunnelColumns[required] {\n\t\t\tt.Fatalf(\"expected tunnel column %q to exist after migration\", required)\n\t\t}\n\t}\n}\n\nfunc readTableColumns(t *testing.T, db *gorm.DB, table string) map[string]bool {\n\tt.Helper()\n\n\tcolumnTypes, err := db.Migrator().ColumnTypes(table)\n\tif err != nil {\n\t\tt.Fatalf(\"inspect %s columns: %v\", table, err)\n\t}\n\n\tcolumns := map[string]bool{}\n\tfor _, col := range columnTypes {\n\t\tname := strings.TrimSpace(col.Name())\n\t\tif name == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tcolumns[name] = true\n\t}\n\n\treturn columns\n}\n",
|
||
"additions": 90,
|
||
"deletions": 0
|
||
}
|
||
],
|
||
"diagnostics": {
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 161,
|
||
"character": 13
|
||
},
|
||
"end": {
|
||
"line": 161,
|
||
"character": 24
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 356,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 356,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 436,
|
||
"character": 74
|
||
},
|
||
"end": {
|
||
"line": 436,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 573,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 573,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 583,
|
||
"character": 77
|
||
},
|
||
"end": {
|
||
"line": 583,
|
||
"character": 88
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 596,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 596,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 603,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 603,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 611,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 611,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 624,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 624,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 632,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 632,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 634,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 634,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 654,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 654,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 662,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 662,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 664,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 664,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 676,
|
||
"character": 53
|
||
},
|
||
"end": {
|
||
"line": 676,
|
||
"character": 64
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 684,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 684,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 686,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 686,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 696,
|
||
"character": 50
|
||
},
|
||
"end": {
|
||
"line": 696,
|
||
"character": 61
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 728,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 728,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 734,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 734,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 750,
|
||
"character": 75
|
||
},
|
||
"end": {
|
||
"line": 750,
|
||
"character": 86
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 769,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 769,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 771,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 771,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 776,
|
||
"character": 64
|
||
},
|
||
"end": {
|
||
"line": 776,
|
||
"character": 75
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 790,
|
||
"character": 28
|
||
},
|
||
"end": {
|
||
"line": 790,
|
||
"character": 39
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 792,
|
||
"character": 35
|
||
},
|
||
"end": {
|
||
"line": 792,
|
||
"character": 46
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 797,
|
||
"character": 49
|
||
},
|
||
"end": {
|
||
"line": 797,
|
||
"character": 60
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 807,
|
||
"character": 40
|
||
},
|
||
"end": {
|
||
"line": 807,
|
||
"character": 51
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 811,
|
||
"character": 31
|
||
},
|
||
"end": {
|
||
"line": 811,
|
||
"character": 42
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 817,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 817,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 818,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 818,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 819,
|
||
"character": 39
|
||
},
|
||
"end": {
|
||
"line": 819,
|
||
"character": 50
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 839,
|
||
"character": 47
|
||
},
|
||
"end": {
|
||
"line": 839,
|
||
"character": 58
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 856,
|
||
"character": 24
|
||
},
|
||
"end": {
|
||
"line": 856,
|
||
"character": 35
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 873,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 873,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 879,
|
||
"character": 51
|
||
},
|
||
"end": {
|
||
"line": 879,
|
||
"character": 62
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 883,
|
||
"character": 56
|
||
},
|
||
"end": {
|
||
"line": 883,
|
||
"character": 67
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 897,
|
||
"character": 33
|
||
},
|
||
"end": {
|
||
"line": 897,
|
||
"character": 44
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 910,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 910,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 921,
|
||
"character": 54
|
||
},
|
||
"end": {
|
||
"line": 921,
|
||
"character": 65
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 929,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 929,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 935,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 935,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 944,
|
||
"character": 52
|
||
},
|
||
"end": {
|
||
"line": 944,
|
||
"character": 63
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 952,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 952,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 958,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 958,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 967,
|
||
"character": 58
|
||
},
|
||
"end": {
|
||
"line": 967,
|
||
"character": 69
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 990,
|
||
"character": 29
|
||
},
|
||
"end": {
|
||
"line": 990,
|
||
"character": 40
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 992,
|
||
"character": 37
|
||
},
|
||
"end": {
|
||
"line": 992,
|
||
"character": 48
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1064,
|
||
"character": 84
|
||
},
|
||
"end": {
|
||
"line": 1064,
|
||
"character": 95
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1130,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 1130,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1146,
|
||
"character": 83
|
||
},
|
||
"end": {
|
||
"line": 1146,
|
||
"character": 94
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1170,
|
||
"character": 90
|
||
},
|
||
"end": {
|
||
"line": 1170,
|
||
"character": 101
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1184,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1184,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1404,
|
||
"character": 85
|
||
},
|
||
"end": {
|
||
"line": 1404,
|
||
"character": 96
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1421,
|
||
"character": 130
|
||
},
|
||
"end": {
|
||
"line": 1421,
|
||
"character": 141
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 1442,
|
||
"character": 21
|
||
},
|
||
"end": {
|
||
"line": 1442,
|
||
"character": 32
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2382,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2382,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2397,
|
||
"character": 23
|
||
},
|
||
"end": {
|
||
"line": 2397,
|
||
"character": 34
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2503,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2503,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2591,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 2591,
|
||
"character": 38
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2713,
|
||
"character": 16
|
||
},
|
||
"end": {
|
||
"line": 2713,
|
||
"character": 29
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "stringscut",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize#stringscut"
|
||
},
|
||
"source": "stringscut",
|
||
"message": "strings.Index can be simplified using strings.Cut"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2757,
|
||
"character": 15
|
||
},
|
||
"end": {
|
||
"line": 2757,
|
||
"character": 28
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#stringsseq"
|
||
},
|
||
"source": "stringsseq",
|
||
"message": "Ranging over SplitSeq is more efficient"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2803,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2803,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2810,
|
||
"character": 38
|
||
},
|
||
"end": {
|
||
"line": 2810,
|
||
"character": 49
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
},
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 2818,
|
||
"character": 36
|
||
},
|
||
"end": {
|
||
"line": 2818,
|
||
"character": 47
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#any"
|
||
},
|
||
"source": "any",
|
||
"message": "interface{} can be replaced by any"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/repo/repository_migrate_test.go": [],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/internal/store/model/model.go": [
|
||
{
|
||
"range": {
|
||
"start": {
|
||
"line": 230,
|
||
"character": 27
|
||
},
|
||
"end": {
|
||
"line": 230,
|
||
"character": 85
|
||
}
|
||
},
|
||
"severity": 4,
|
||
"code": "default",
|
||
"codeDescription": {
|
||
"href": "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#omitzero"
|
||
},
|
||
"source": "omitzero",
|
||
"message": "Omitempty has no effect on nested struct fields"
|
||
}
|
||
],
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go": []
|
||
},
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504178460,
|
||
"end": 1772504178679
|
||
}
|
||
},
|
||
"id": "prt_cb17b302e0013n3SXtefLJQqJ3",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae270001KWrHGCdq3xm8AB"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "957a738022906067f12c44030f755117d74c1450",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 113408,
|
||
"input": 111361,
|
||
"output": 2047,
|
||
"reasoning": 939,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17b5ffc001jqF2R8fT6clHMK",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae270001KWrHGCdq3xm8AB"
|
||
},
|
||
{
|
||
"type": "patch",
|
||
"hash": "5c01ab1df9ccc5f4241a95740b96b26d5642e9f3",
|
||
"files": [
|
||
"/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend/tests/contract/migration_contract_test.go"
|
||
],
|
||
"id": "prt_cb17b6057001xsoyfuEnRpPk11",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17ae270001KWrHGCdq3xm8AB"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504178782,
|
||
"completed": 1772504187807
|
||
},
|
||
"parentID": "msg_cb17ae254001MY3UZJ0wflUc7n",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 112605,
|
||
"input": 112497,
|
||
"output": 108,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "tool-calls",
|
||
"id": "msg_cb17b605e0013yX8GWo9H4X9hL",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "957a738022906067f12c44030f755117d74c1450",
|
||
"id": "prt_cb17b76e5001A7wF73fbJuttxw",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b605e0013yX8GWo9H4X9hL"
|
||
},
|
||
{
|
||
"type": "tool",
|
||
"callID": "call_7Lr0gbhArmR5JGp11LMuMk81",
|
||
"tool": "bash",
|
||
"state": {
|
||
"status": "completed",
|
||
"input": {
|
||
"command": "go test ./tests/contract -run \"TestOpenMigratesLegacyNodeDualStackColumns|TestOpenMigratesVeryLegacyNodeAndTunnelColumns\"",
|
||
"timeout": 600000,
|
||
"workdir": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula/go-backend",
|
||
"description": "Runs extended SQLite legacy migration contract tests"
|
||
},
|
||
"output": "ok \tgo-backend/tests/contract\t0.594s\n",
|
||
"title": "Runs extended SQLite legacy migration contract tests",
|
||
"metadata": {
|
||
"output": "ok \tgo-backend/tests/contract\t0.594s\n",
|
||
"exit": 0,
|
||
"description": "Runs extended SQLite legacy migration contract tests",
|
||
"truncated": false
|
||
},
|
||
"time": {
|
||
"start": 1772504186114,
|
||
"end": 1772504187755
|
||
}
|
||
},
|
||
"id": "prt_cb17b76e6001C2c0F35wZC4IVY",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b605e0013yX8GWo9H4X9hL"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "tool-calls",
|
||
"snapshot": "957a738022906067f12c44030f755117d74c1450",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 112605,
|
||
"input": 112497,
|
||
"output": 108,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17b836c001I4Mdj0ISDwjK7u",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b605e0013yX8GWo9H4X9hL"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"info": {
|
||
"role": "assistant",
|
||
"time": {
|
||
"created": 1772504187810,
|
||
"completed": 1772504197541
|
||
},
|
||
"parentID": "msg_cb17ae254001MY3UZJ0wflUc7n",
|
||
"modelID": "gpt-5.3-codex",
|
||
"providerID": "api",
|
||
"mode": "build",
|
||
"agent": "build",
|
||
"path": {
|
||
"cwd": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula",
|
||
"root": "/Users/sagit/.local/share/opencode/worktree/13650be91caa3a9e08a2e63127fef62757658384/mighty-nebula"
|
||
},
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 112969,
|
||
"input": 112630,
|
||
"output": 339,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"variant": "xhigh",
|
||
"finish": "stop",
|
||
"id": "msg_cb17b83a2001werQrpCSigmESb",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu"
|
||
},
|
||
"parts": [
|
||
{
|
||
"type": "step-start",
|
||
"snapshot": "957a738022906067f12c44030f755117d74c1450",
|
||
"id": "prt_cb17b9440001X3aV8np0xXisI7",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b83a2001werQrpCSigmESb"
|
||
},
|
||
{
|
||
"type": "text",
|
||
"text": "已完成这一轮“跨表全量列对比(model vs 旧库补列逻辑)”,并做了可执行验证。\n\n我做了什么:\n\n- 对比了 `model.Node` / `model.Tunnel` 字段与 `prepareSQLiteLegacyColumns` 的补列清单。\n- 结论:\n - `Node` 目前应补的历史增量列:`server_ip_v4`, `server_ip_v6`, `extra_ips`, `tcp_listen_addr`, `udp_listen_addr`, `inx`, `is_remote`, `remote_url`, `remote_token`, `remote_config`\n - `Tunnel` 应补:`inx`, `ip_preference`\n- 我已把这个对齐结果固化为回归测试,新增了一个“更老版本库”场景验证:\n - 新测试:`TestOpenMigratesVeryLegacyNodeAndTunnelColumns`\n - 文件:`go-backend/tests/contract/migration_contract_test.go`\n - 该测试从极简旧表结构启动,验证上述所有列都会被自动补齐。\n- 测试结果:通过\n - `go test ./tests/contract -run \"TestOpenMigratesLegacyNodeDualStackColumns|TestOpenMigratesVeryLegacyNodeAndTunnelColumns\"`\n\n最终结论(本轮排查范围内):\n\n- **Node/Tunnel 这条旧库升级链路已对齐且有测试兜底**。\n- 你这类 “no such column” 的同类风险,针对这两个核心表已经基本封住。",
|
||
"time": {
|
||
"start": 1772504197446,
|
||
"end": 1772504197446
|
||
},
|
||
"id": "prt_cb17b9441001c4X7bHsAfg1dYB",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b83a2001werQrpCSigmESb"
|
||
},
|
||
{
|
||
"type": "step-finish",
|
||
"reason": "stop",
|
||
"snapshot": "957a738022906067f12c44030f755117d74c1450",
|
||
"cost": 0,
|
||
"tokens": {
|
||
"total": 112969,
|
||
"input": 112630,
|
||
"output": 339,
|
||
"reasoning": 0,
|
||
"cache": {
|
||
"read": 0,
|
||
"write": 0
|
||
}
|
||
},
|
||
"id": "prt_cb17ba949001zGtMcD5GFVfC19",
|
||
"sessionID": "ses_34ec18cadffeoVVSjfPXGJaxBu",
|
||
"messageID": "msg_cb17b83a2001werQrpCSigmESb"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|