@robsun/create-keystone-app 0.1.17 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/bin/create-keystone-app.js +2 -80
- package/package.json +1 -1
- package/template/README.md +5 -13
- package/template/apps/server/config.example.yaml +0 -1
- package/template/apps/server/config.yaml +0 -1
- package/template/apps/server/internal/modules/example/api/handler/item_handler.go +162 -0
- package/template/apps/server/internal/modules/example/bootstrap/migrations/item.go +21 -0
- package/template/apps/server/internal/modules/example/bootstrap/seeds/item.go +33 -0
- package/template/apps/server/internal/modules/example/domain/models/item.go +30 -0
- package/template/apps/server/internal/modules/{demo → example}/domain/service/errors.go +1 -1
- package/template/apps/server/internal/modules/example/domain/service/item_service.go +110 -0
- package/template/apps/server/internal/modules/example/infra/repository/item_repository.go +49 -0
- package/template/apps/server/internal/modules/example/module.go +55 -17
- package/template/apps/server/internal/modules/manifest.go +1 -3
- package/template/apps/web/src/app.config.ts +1 -1
- package/template/apps/web/src/main.tsx +0 -1
- package/template/apps/web/src/modules/example/help/faq.md +23 -0
- package/template/apps/web/src/modules/example/help/items.md +26 -0
- package/template/apps/web/src/modules/example/help/overview.md +18 -4
- package/template/apps/web/src/modules/example/pages/ExampleItemsPage.tsx +227 -0
- package/template/apps/web/src/modules/example/routes.tsx +33 -10
- package/template/apps/web/src/modules/example/services/exampleItems.ts +32 -0
- package/template/apps/web/src/modules/example/types.ts +10 -0
- package/template/docs/CONVENTIONS.md +44 -0
- package/template/docs/GETTING_STARTED.md +54 -0
- package/template/package.json +1 -1
- package/template/scripts/check-modules.js +7 -1
- package/template/apps/server/internal/modules/demo/api/handler/task_handler.go +0 -152
- package/template/apps/server/internal/modules/demo/bootstrap/migrations/task.go +0 -21
- package/template/apps/server/internal/modules/demo/bootstrap/seeds/task.go +0 -33
- package/template/apps/server/internal/modules/demo/domain/models/task.go +0 -30
- package/template/apps/server/internal/modules/demo/domain/service/task_service.go +0 -95
- package/template/apps/server/internal/modules/demo/infra/repository/task_repository.go +0 -49
- package/template/apps/server/internal/modules/demo/module.go +0 -91
- package/template/apps/server/internal/modules/example/handlers.go +0 -19
- package/template/apps/web/src/modules/demo/help/overview.md +0 -12
- package/template/apps/web/src/modules/demo/index.ts +0 -7
- package/template/apps/web/src/modules/demo/pages/DemoTasksPage.tsx +0 -185
- package/template/apps/web/src/modules/demo/routes.tsx +0 -43
- package/template/apps/web/src/modules/demo/services/demoTasks.ts +0 -28
- package/template/apps/web/src/modules/demo/types.ts +0 -9
- package/template/apps/web/src/modules/example/pages/ExamplePage.tsx +0 -41
- package/template/apps/web/src/modules/example/services/api.ts +0 -8
package/README.md
CHANGED
|
@@ -8,24 +8,25 @@ pnpm dlx @robsun/create-keystone-app <dir> [options]
|
|
|
8
8
|
|
|
9
9
|
## 选项
|
|
10
10
|
- `<dir>`:目标目录(必填),可为新目录名或 `.`(当前目录)。
|
|
11
|
-
- `--profile <starter|full>`:模板档位,默认 `starter`。
|
|
12
|
-
- `--demo` / `--no-demo`:是否包含 Demo 模块(`full` 默认包含)。
|
|
13
11
|
- `--db <sqlite|postgres>`:数据库驱动(默认 `sqlite`)。
|
|
14
12
|
- `--queue <memory|redis>`:队列驱动(默认 `memory`)。
|
|
15
13
|
- `--storage <local|s3>`:存储驱动(默认 `local`)。
|
|
16
14
|
|
|
17
15
|
## 示例
|
|
18
16
|
```bash
|
|
19
|
-
npx @robsun/create-keystone-app my-app --
|
|
17
|
+
npx @robsun/create-keystone-app my-app --db=postgres --queue=redis --storage=s3
|
|
20
18
|
```
|
|
21
19
|
|
|
22
20
|
## 初始化后操作
|
|
23
21
|
```bash
|
|
24
22
|
cd <dir>
|
|
25
23
|
pnpm install
|
|
24
|
+
pnpm server:dev
|
|
25
|
+
pnpm web:dev
|
|
26
26
|
pnpm dev
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
## 端口与
|
|
29
|
+
## 端口与 Example
|
|
30
30
|
- Web 默认端口:`3000`;后端默认端口:`8080`。
|
|
31
|
-
-
|
|
31
|
+
- Example API:`/api/v1/example/items`。
|
|
32
|
+
- 权限:`example:item:view`、`example:item:manage`。
|
|
@@ -6,9 +6,6 @@ const usage = [
|
|
|
6
6
|
'Usage: create-keystone-app <dir> [options]',
|
|
7
7
|
'',
|
|
8
8
|
'Options:',
|
|
9
|
-
' --profile <starter|full> Template profile (default: starter)',
|
|
10
|
-
' --demo Include demo module',
|
|
11
|
-
' --no-demo Exclude demo module',
|
|
12
9
|
' --db <sqlite|postgres> Database driver (default: sqlite)',
|
|
13
10
|
' --queue <memory|redis> Queue driver (default: memory)',
|
|
14
11
|
' --storage <local|s3> Storage driver (default: local)',
|
|
@@ -26,19 +23,10 @@ if (!args.target) {
|
|
|
26
23
|
process.exit(1);
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
const profile = normalizeChoice(args.profile, ['starter', 'full'], 'profile') || 'starter';
|
|
30
26
|
const db = normalizeChoice(args.db, ['sqlite', 'postgres'], 'db') || 'sqlite';
|
|
31
27
|
const queue = normalizeChoice(args.queue, ['memory', 'redis'], 'queue') || 'memory';
|
|
32
28
|
const storage = normalizeChoice(args.storage, ['local', 's3'], 'storage') || 'local';
|
|
33
29
|
|
|
34
|
-
let includeDemo = profile === 'full';
|
|
35
|
-
if (args.demo === true) {
|
|
36
|
-
includeDemo = true;
|
|
37
|
-
}
|
|
38
|
-
if (args.demo === false) {
|
|
39
|
-
includeDemo = false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
30
|
const targetDir = path.resolve(process.cwd(), args.target);
|
|
43
31
|
const targetName = args.target === '.'
|
|
44
32
|
? path.basename(process.cwd())
|
|
@@ -62,15 +50,12 @@ copyDir(templateDir, targetDir, {
|
|
|
62
50
|
|
|
63
51
|
applyConfigOptions(targetDir, { db, queue, storage });
|
|
64
52
|
|
|
65
|
-
if (!includeDemo) {
|
|
66
|
-
stripDemo(targetDir);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
53
|
console.log(`Created ${targetName}`);
|
|
70
54
|
console.log('Next steps:');
|
|
71
55
|
console.log(` cd ${args.target}`);
|
|
72
56
|
console.log(' pnpm install');
|
|
73
57
|
console.log(' pnpm server:dev');
|
|
58
|
+
console.log(' pnpm web:dev');
|
|
74
59
|
console.log(' pnpm dev');
|
|
75
60
|
|
|
76
61
|
function normalizePackageName(name) {
|
|
@@ -119,49 +104,6 @@ function shouldSkipDir(name) {
|
|
|
119
104
|
return name === 'node_modules' || name === '.git';
|
|
120
105
|
}
|
|
121
106
|
|
|
122
|
-
function stripDemo(targetDir) {
|
|
123
|
-
removePath(path.join(targetDir, 'apps', 'web', 'src', 'modules', 'demo'));
|
|
124
|
-
removePath(path.join(targetDir, 'apps', 'server', 'internal', 'modules', 'demo'));
|
|
125
|
-
|
|
126
|
-
updateFile(path.join(targetDir, 'apps', 'web', 'src', 'main.tsx'), (content) =>
|
|
127
|
-
content.replace(/^\s*import ['"]\.\/modules\/demo['"];?\r?\n/m, '')
|
|
128
|
-
);
|
|
129
|
-
updateFile(path.join(targetDir, 'apps', 'web', 'src', 'app.config.ts'), (content) =>
|
|
130
|
-
content.replace(/,\s*['"]demo['"]/, '')
|
|
131
|
-
);
|
|
132
|
-
updateFile(path.join(targetDir, 'apps', 'server', 'config.yaml'), (content) =>
|
|
133
|
-
content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
|
|
134
|
-
);
|
|
135
|
-
updateFile(path.join(targetDir, 'apps', 'server', 'config.example.yaml'), (content) =>
|
|
136
|
-
content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
|
|
137
|
-
);
|
|
138
|
-
updateFile(path.join(targetDir, 'README.md'), (content) =>
|
|
139
|
-
content.replace(/<!-- DEMO_START -->[\s\S]*?<!-- DEMO_END -->\s*/m, '')
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
const manifestPath = path.join(
|
|
143
|
-
targetDir,
|
|
144
|
-
'apps',
|
|
145
|
-
'server',
|
|
146
|
-
'internal',
|
|
147
|
-
'modules',
|
|
148
|
-
'manifest.go'
|
|
149
|
-
);
|
|
150
|
-
const manifest = `package modules
|
|
151
|
-
|
|
152
|
-
import (
|
|
153
|
-
\texample "__APP_NAME__/apps/server/internal/modules/example"
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
// RegisterAll wires the module registry for this app.
|
|
157
|
-
func RegisterAll() {
|
|
158
|
-
\tClear()
|
|
159
|
-
\tRegister(example.NewModule())
|
|
160
|
-
}
|
|
161
|
-
`;
|
|
162
|
-
fs.writeFileSync(manifestPath, manifest, 'utf8');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
107
|
function applyConfigOptions(targetDir, options) {
|
|
166
108
|
const configFiles = [
|
|
167
109
|
path.join(targetDir, 'apps', 'server', 'config.yaml'),
|
|
@@ -201,12 +143,6 @@ function updateYamlSectionValue(content, section, key, value) {
|
|
|
201
143
|
return content.replace(pattern, `$1${value}$3`);
|
|
202
144
|
}
|
|
203
145
|
|
|
204
|
-
function removePath(target) {
|
|
205
|
-
if (fs.existsSync(target)) {
|
|
206
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
146
|
function updateFile(filePath, updater) {
|
|
211
147
|
if (!fs.existsSync(filePath)) {
|
|
212
148
|
return;
|
|
@@ -227,28 +163,14 @@ function shouldMakeExecutable(filePath) {
|
|
|
227
163
|
}
|
|
228
164
|
|
|
229
165
|
function parseArgs(argv) {
|
|
230
|
-
const out = {
|
|
166
|
+
const out = {};
|
|
231
167
|
for (let i = 0; i < argv.length; i++) {
|
|
232
168
|
const arg = argv[i];
|
|
233
|
-
if (arg === '--demo') {
|
|
234
|
-
out.demo = true;
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
if (arg === '--no-demo') {
|
|
238
|
-
out.demo = false;
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
169
|
if (arg === '--help' || arg === '-h') {
|
|
242
170
|
out.help = true;
|
|
243
171
|
continue;
|
|
244
172
|
}
|
|
245
173
|
|
|
246
|
-
const profile = readValueOption(arg, argv, i, '--profile');
|
|
247
|
-
if (profile) {
|
|
248
|
-
out.profile = profile.value;
|
|
249
|
-
i += profile.skip;
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
174
|
const db = readValueOption(arg, argv, i, '--db');
|
|
253
175
|
if (db) {
|
|
254
176
|
out.db = db.value;
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -23,7 +23,7 @@ pnpm web:dev
|
|
|
23
23
|
- 复制 `apps/web/.env.example` 为 `apps/web/.env`(Vite 会读取)。
|
|
24
24
|
- 检查 `apps/server/config.yaml`(Go 运行配置)。
|
|
25
25
|
- 需要外部依赖时参考 `docker-compose.yml`。
|
|
26
|
-
- 建议阅读:`docs/CONVENTIONS.md`、`docs/CODE_STYLE.md`、`apps/web/README.md`、`apps/server/README.md`。
|
|
26
|
+
- 建议阅读:`docs/GETTING_STARTED.md`、`docs/CONVENTIONS.md`、`docs/CODE_STYLE.md`、`apps/web/README.md`、`apps/server/README.md`。
|
|
27
27
|
|
|
28
28
|
## 常用命令
|
|
29
29
|
- `pnpm dev`:跨平台开发启动(Air + Vite)。
|
|
@@ -50,18 +50,10 @@ pnpm web:dev
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
## Example 模块(默认)
|
|
53
|
-
|
|
54
|
-
- 菜单:Example
|
|
55
|
-
- API:`/api/v1/example/
|
|
56
|
-
- 权限:`example:view`
|
|
57
|
-
|
|
58
|
-
<!-- DEMO_START -->
|
|
59
|
-
## Demo 模块(可选)
|
|
60
|
-
如果需要 Demo,可在创建时使用 `--demo` 或 `--profile=full`。
|
|
61
|
-
- 菜单:Demo Tasks
|
|
62
|
-
- API:`/api/v1/demo/tasks`
|
|
63
|
-
- 权限:`demo:task:view`、`demo:task:manage`
|
|
64
|
-
<!-- DEMO_END -->
|
|
53
|
+
Example 模块展示完整的 CRUD 模式、权限命名与 DDD 分层结构。
|
|
54
|
+
- 菜单:Example Items
|
|
55
|
+
- API:`/api/v1/example/items`
|
|
56
|
+
- 权限:`example:item:view`、`example:item:manage`
|
|
65
57
|
|
|
66
58
|
## 默认登录
|
|
67
59
|
- Tenant code: `default`
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
package handler
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
|
|
6
|
+
"github.com/gin-gonic/gin"
|
|
7
|
+
hcommon "github.com/robsuncn/keystone/api/handler/common"
|
|
8
|
+
"github.com/robsuncn/keystone/api/response"
|
|
9
|
+
|
|
10
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
11
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/service"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type ItemHandler struct {
|
|
15
|
+
items *service.ItemService
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func NewItemHandler(items *service.ItemService) *ItemHandler {
|
|
19
|
+
if items == nil {
|
|
20
|
+
return nil
|
|
21
|
+
}
|
|
22
|
+
return &ItemHandler{items: items}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type itemInput struct {
|
|
26
|
+
Title string `json:"title"`
|
|
27
|
+
Description string `json:"description"`
|
|
28
|
+
Status models.ItemStatus `json:"status"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type itemUpdateInput struct {
|
|
32
|
+
Title *string `json:"title"`
|
|
33
|
+
Description *string `json:"description"`
|
|
34
|
+
Status *models.ItemStatus `json:"status"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultTenantID uint = 1
|
|
38
|
+
|
|
39
|
+
func (h *ItemHandler) List(c *gin.Context) {
|
|
40
|
+
if h == nil || h.items == nil {
|
|
41
|
+
response.ServiceUnavailable(c, "example items unavailable")
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
tenantID := resolveTenantID(c)
|
|
45
|
+
|
|
46
|
+
items, err := h.items.List(c.Request.Context(), tenantID)
|
|
47
|
+
if err != nil {
|
|
48
|
+
response.InternalError(c, "failed to load example items")
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
response.Success(c, gin.H{"items": items})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (h *ItemHandler) Create(c *gin.Context) {
|
|
56
|
+
if h == nil || h.items == nil {
|
|
57
|
+
response.ServiceUnavailable(c, "example items unavailable")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
tenantID := resolveTenantID(c)
|
|
61
|
+
|
|
62
|
+
var input itemInput
|
|
63
|
+
if err := c.ShouldBindJSON(&input); err != nil {
|
|
64
|
+
response.BadRequest(c, "invalid payload")
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
item, err := h.items.Create(c.Request.Context(), tenantID, service.ItemInput{
|
|
69
|
+
Title: input.Title,
|
|
70
|
+
Description: input.Description,
|
|
71
|
+
Status: input.Status,
|
|
72
|
+
})
|
|
73
|
+
if err != nil {
|
|
74
|
+
switch {
|
|
75
|
+
case errors.Is(err, service.ErrTitleRequired):
|
|
76
|
+
response.BadRequest(c, "title is required")
|
|
77
|
+
case errors.Is(err, service.ErrStatusInvalid):
|
|
78
|
+
response.BadRequest(c, "invalid status")
|
|
79
|
+
default:
|
|
80
|
+
response.InternalError(c, "failed to create example item")
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
response.Created(c, item)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (h *ItemHandler) Update(c *gin.Context) {
|
|
89
|
+
if h == nil || h.items == nil {
|
|
90
|
+
response.ServiceUnavailable(c, "example items unavailable")
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
tenantID := resolveTenantID(c)
|
|
94
|
+
|
|
95
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
96
|
+
if err != nil || id == 0 {
|
|
97
|
+
response.BadRequest(c, "invalid id")
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
var input itemUpdateInput
|
|
102
|
+
if err := c.ShouldBindJSON(&input); err != nil {
|
|
103
|
+
response.BadRequest(c, "invalid payload")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
item, err := h.items.Update(c.Request.Context(), tenantID, id, service.ItemUpdateInput{
|
|
108
|
+
Title: input.Title,
|
|
109
|
+
Description: input.Description,
|
|
110
|
+
Status: input.Status,
|
|
111
|
+
})
|
|
112
|
+
if err != nil {
|
|
113
|
+
switch {
|
|
114
|
+
case errors.Is(err, service.ErrItemNotFound):
|
|
115
|
+
response.NotFound(c, "item not found")
|
|
116
|
+
case errors.Is(err, service.ErrTitleRequired):
|
|
117
|
+
response.BadRequest(c, "title is required")
|
|
118
|
+
case errors.Is(err, service.ErrStatusInvalid):
|
|
119
|
+
response.BadRequest(c, "invalid status")
|
|
120
|
+
default:
|
|
121
|
+
response.InternalError(c, "failed to update example item")
|
|
122
|
+
}
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
response.SuccessWithMessage(c, "updated", item)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func (h *ItemHandler) Delete(c *gin.Context) {
|
|
130
|
+
if h == nil || h.items == nil {
|
|
131
|
+
response.ServiceUnavailable(c, "example items unavailable")
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
tenantID := resolveTenantID(c)
|
|
135
|
+
|
|
136
|
+
id, err := hcommon.ParseUintParam(c, "id")
|
|
137
|
+
if err != nil || id == 0 {
|
|
138
|
+
response.BadRequest(c, "invalid id")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if err := h.items.Delete(c.Request.Context(), tenantID, id); err != nil {
|
|
143
|
+
if errors.Is(err, service.ErrItemNotFound) {
|
|
144
|
+
response.NotFound(c, "item not found")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
response.InternalError(c, "failed to delete example item")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
response.SuccessWithMessage(c, "deleted", gin.H{"id": id})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func resolveTenantID(c *gin.Context) uint {
|
|
155
|
+
if c == nil {
|
|
156
|
+
return defaultTenantID
|
|
157
|
+
}
|
|
158
|
+
if tenantID, ok := hcommon.GetTenantID(c); ok && tenantID > 0 {
|
|
159
|
+
return tenantID
|
|
160
|
+
}
|
|
161
|
+
return defaultTenantID
|
|
162
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package migrations
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"log"
|
|
6
|
+
|
|
7
|
+
"gorm.io/gorm"
|
|
8
|
+
|
|
9
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func Migrate(db *gorm.DB) error {
|
|
13
|
+
if db == nil {
|
|
14
|
+
return nil
|
|
15
|
+
}
|
|
16
|
+
log.Println("[example] Running migrations...")
|
|
17
|
+
if err := db.AutoMigrate(&models.ExampleItem{}); err != nil {
|
|
18
|
+
return fmt.Errorf("auto migrate example_items: %w", err)
|
|
19
|
+
}
|
|
20
|
+
return nil
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package seeds
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"log"
|
|
5
|
+
|
|
6
|
+
"gorm.io/gorm"
|
|
7
|
+
|
|
8
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func Seed(db *gorm.DB) error {
|
|
12
|
+
if db == nil {
|
|
13
|
+
return nil
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var count int64
|
|
17
|
+
if err := db.Model(&models.ExampleItem{}).Count(&count).Error; err != nil {
|
|
18
|
+
return err
|
|
19
|
+
}
|
|
20
|
+
if count > 0 {
|
|
21
|
+
return nil
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
log.Println("[example] Seeding initial data...")
|
|
25
|
+
items := []models.ExampleItem{
|
|
26
|
+
{Title: "First example item", Description: "A seeded record to explore CRUD.", Status: models.StatusActive},
|
|
27
|
+
{Title: "Inactive example item", Description: "Use status to drive UI state.", Status: models.StatusInactive},
|
|
28
|
+
}
|
|
29
|
+
for i := range items {
|
|
30
|
+
items[i].TenantID = 1
|
|
31
|
+
}
|
|
32
|
+
return db.Create(&items).Error
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package models
|
|
2
|
+
|
|
3
|
+
import "github.com/robsuncn/keystone/domain/models"
|
|
4
|
+
|
|
5
|
+
type ItemStatus string
|
|
6
|
+
|
|
7
|
+
const (
|
|
8
|
+
StatusActive ItemStatus = "active"
|
|
9
|
+
StatusInactive ItemStatus = "inactive"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func (s ItemStatus) IsValid() bool {
|
|
13
|
+
switch s {
|
|
14
|
+
case StatusActive, StatusInactive:
|
|
15
|
+
return true
|
|
16
|
+
default:
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ExampleItem struct {
|
|
22
|
+
models.BaseModel
|
|
23
|
+
Title string `gorm:"size:200;not null" json:"title"`
|
|
24
|
+
Description string `gorm:"size:1000" json:"description"`
|
|
25
|
+
Status ItemStatus `gorm:"size:20;not null;default:'active'" json:"status"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func (ExampleItem) TableName() string {
|
|
29
|
+
return "example_items"
|
|
30
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
package service
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type ItemRepository interface {
|
|
11
|
+
List(ctx context.Context, tenantID uint) ([]models.ExampleItem, error)
|
|
12
|
+
FindByID(tenantID, id uint) (*models.ExampleItem, error)
|
|
13
|
+
Create(ctx context.Context, item *models.ExampleItem) error
|
|
14
|
+
Update(ctx context.Context, item *models.ExampleItem) error
|
|
15
|
+
Delete(ctx context.Context, item *models.ExampleItem) error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ItemService struct {
|
|
19
|
+
items ItemRepository
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ItemInput struct {
|
|
23
|
+
Title string
|
|
24
|
+
Description string
|
|
25
|
+
Status models.ItemStatus
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ItemUpdateInput struct {
|
|
29
|
+
Title *string
|
|
30
|
+
Description *string
|
|
31
|
+
Status *models.ItemStatus
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func NewItemService(items ItemRepository) *ItemService {
|
|
35
|
+
return &ItemService{items: items}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (s *ItemService) List(ctx context.Context, tenantID uint) ([]models.ExampleItem, error) {
|
|
39
|
+
return s.items.List(ctx, tenantID)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (s *ItemService) Create(ctx context.Context, tenantID uint, input ItemInput) (*models.ExampleItem, error) {
|
|
43
|
+
title := strings.TrimSpace(input.Title)
|
|
44
|
+
if title == "" {
|
|
45
|
+
return nil, ErrTitleRequired
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
status := input.Status
|
|
49
|
+
if status == "" {
|
|
50
|
+
status = models.StatusActive
|
|
51
|
+
}
|
|
52
|
+
if !status.IsValid() {
|
|
53
|
+
return nil, ErrStatusInvalid
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
item := &models.ExampleItem{
|
|
57
|
+
Title: title,
|
|
58
|
+
Description: strings.TrimSpace(input.Description),
|
|
59
|
+
Status: status,
|
|
60
|
+
}
|
|
61
|
+
item.TenantID = tenantID
|
|
62
|
+
|
|
63
|
+
if err := s.items.Create(ctx, item); err != nil {
|
|
64
|
+
return nil, err
|
|
65
|
+
}
|
|
66
|
+
return item, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func (s *ItemService) Update(
|
|
70
|
+
ctx context.Context,
|
|
71
|
+
tenantID, id uint,
|
|
72
|
+
input ItemUpdateInput,
|
|
73
|
+
) (*models.ExampleItem, error) {
|
|
74
|
+
item, err := s.items.FindByID(tenantID, id)
|
|
75
|
+
if err != nil {
|
|
76
|
+
return nil, err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if input.Title != nil {
|
|
80
|
+
title := strings.TrimSpace(*input.Title)
|
|
81
|
+
if title == "" {
|
|
82
|
+
return nil, ErrTitleRequired
|
|
83
|
+
}
|
|
84
|
+
item.Title = title
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if input.Description != nil {
|
|
88
|
+
item.Description = strings.TrimSpace(*input.Description)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if input.Status != nil {
|
|
92
|
+
if !input.Status.IsValid() {
|
|
93
|
+
return nil, ErrStatusInvalid
|
|
94
|
+
}
|
|
95
|
+
item.Status = *input.Status
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if err := s.items.Update(ctx, item); err != nil {
|
|
99
|
+
return nil, err
|
|
100
|
+
}
|
|
101
|
+
return item, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func (s *ItemService) Delete(ctx context.Context, tenantID, id uint) error {
|
|
105
|
+
item, err := s.items.FindByID(tenantID, id)
|
|
106
|
+
if err != nil {
|
|
107
|
+
return err
|
|
108
|
+
}
|
|
109
|
+
return s.items.Delete(ctx, item)
|
|
110
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package repository
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
|
|
7
|
+
"gorm.io/gorm"
|
|
8
|
+
|
|
9
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/models"
|
|
10
|
+
"__APP_NAME__/apps/server/internal/modules/example/domain/service"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type ItemRepository struct {
|
|
14
|
+
db *gorm.DB
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func NewItemRepository(db *gorm.DB) *ItemRepository {
|
|
18
|
+
return &ItemRepository{db: db}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func (r *ItemRepository) List(ctx context.Context, tenantID uint) ([]models.ExampleItem, error) {
|
|
22
|
+
var items []models.ExampleItem
|
|
23
|
+
err := r.db.WithContext(ctx).
|
|
24
|
+
Where("tenant_id = ?", tenantID).
|
|
25
|
+
Order("created_at desc").
|
|
26
|
+
Find(&items).Error
|
|
27
|
+
return items, err
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func (r *ItemRepository) FindByID(tenantID, id uint) (*models.ExampleItem, error) {
|
|
31
|
+
var item models.ExampleItem
|
|
32
|
+
err := r.db.Where("tenant_id = ? AND id = ?", tenantID, id).First(&item).Error
|
|
33
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
34
|
+
return nil, service.ErrItemNotFound
|
|
35
|
+
}
|
|
36
|
+
return &item, err
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (r *ItemRepository) Create(ctx context.Context, item *models.ExampleItem) error {
|
|
40
|
+
return r.db.WithContext(ctx).Create(item).Error
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func (r *ItemRepository) Update(ctx context.Context, item *models.ExampleItem) error {
|
|
44
|
+
return r.db.WithContext(ctx).Save(item).Error
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func (r *ItemRepository) Delete(ctx context.Context, item *models.ExampleItem) error {
|
|
48
|
+
return r.db.WithContext(ctx).Delete(item).Error
|
|
49
|
+
}
|