@robsun/create-keystone-app 0.1.18 → 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 +4 -5
- package/bin/create-keystone-app.js +1 -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,15 +8,13 @@ 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
|
## 初始化后操作
|
|
@@ -28,6 +26,7 @@ pnpm web:dev
|
|
|
28
26
|
pnpm dev
|
|
29
27
|
```
|
|
30
28
|
|
|
31
|
-
## 端口与
|
|
29
|
+
## 端口与 Example
|
|
32
30
|
- Web 默认端口:`3000`;后端默认端口:`8080`。
|
|
33
|
-
-
|
|
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,10 +50,6 @@ 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}`);
|
|
@@ -120,49 +104,6 @@ function shouldSkipDir(name) {
|
|
|
120
104
|
return name === 'node_modules' || name === '.git';
|
|
121
105
|
}
|
|
122
106
|
|
|
123
|
-
function stripDemo(targetDir) {
|
|
124
|
-
removePath(path.join(targetDir, 'apps', 'web', 'src', 'modules', 'demo'));
|
|
125
|
-
removePath(path.join(targetDir, 'apps', 'server', 'internal', 'modules', 'demo'));
|
|
126
|
-
|
|
127
|
-
updateFile(path.join(targetDir, 'apps', 'web', 'src', 'main.tsx'), (content) =>
|
|
128
|
-
content.replace(/^\s*import ['"]\.\/modules\/demo['"];?\r?\n/m, '')
|
|
129
|
-
);
|
|
130
|
-
updateFile(path.join(targetDir, 'apps', 'web', 'src', 'app.config.ts'), (content) =>
|
|
131
|
-
content.replace(/,\s*['"]demo['"]/, '')
|
|
132
|
-
);
|
|
133
|
-
updateFile(path.join(targetDir, 'apps', 'server', 'config.yaml'), (content) =>
|
|
134
|
-
content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
|
|
135
|
-
);
|
|
136
|
-
updateFile(path.join(targetDir, 'apps', 'server', 'config.example.yaml'), (content) =>
|
|
137
|
-
content.replace(/\r?\n\s*-\s*['"]demo['"]\s*/g, '')
|
|
138
|
-
);
|
|
139
|
-
updateFile(path.join(targetDir, 'README.md'), (content) =>
|
|
140
|
-
content.replace(/<!-- DEMO_START -->[\s\S]*?<!-- DEMO_END -->\s*/m, '')
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const manifestPath = path.join(
|
|
144
|
-
targetDir,
|
|
145
|
-
'apps',
|
|
146
|
-
'server',
|
|
147
|
-
'internal',
|
|
148
|
-
'modules',
|
|
149
|
-
'manifest.go'
|
|
150
|
-
);
|
|
151
|
-
const manifest = `package modules
|
|
152
|
-
|
|
153
|
-
import (
|
|
154
|
-
\texample "__APP_NAME__/apps/server/internal/modules/example"
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
// RegisterAll wires the module registry for this app.
|
|
158
|
-
func RegisterAll() {
|
|
159
|
-
\tClear()
|
|
160
|
-
\tRegister(example.NewModule())
|
|
161
|
-
}
|
|
162
|
-
`;
|
|
163
|
-
fs.writeFileSync(manifestPath, manifest, 'utf8');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
107
|
function applyConfigOptions(targetDir, options) {
|
|
167
108
|
const configFiles = [
|
|
168
109
|
path.join(targetDir, 'apps', 'server', 'config.yaml'),
|
|
@@ -202,12 +143,6 @@ function updateYamlSectionValue(content, section, key, value) {
|
|
|
202
143
|
return content.replace(pattern, `$1${value}$3`);
|
|
203
144
|
}
|
|
204
145
|
|
|
205
|
-
function removePath(target) {
|
|
206
|
-
if (fs.existsSync(target)) {
|
|
207
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
146
|
function updateFile(filePath, updater) {
|
|
212
147
|
if (!fs.existsSync(filePath)) {
|
|
213
148
|
return;
|
|
@@ -228,28 +163,14 @@ function shouldMakeExecutable(filePath) {
|
|
|
228
163
|
}
|
|
229
164
|
|
|
230
165
|
function parseArgs(argv) {
|
|
231
|
-
const out = {
|
|
166
|
+
const out = {};
|
|
232
167
|
for (let i = 0; i < argv.length; i++) {
|
|
233
168
|
const arg = argv[i];
|
|
234
|
-
if (arg === '--demo') {
|
|
235
|
-
out.demo = true;
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
if (arg === '--no-demo') {
|
|
239
|
-
out.demo = false;
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
169
|
if (arg === '--help' || arg === '-h') {
|
|
243
170
|
out.help = true;
|
|
244
171
|
continue;
|
|
245
172
|
}
|
|
246
173
|
|
|
247
|
-
const profile = readValueOption(arg, argv, i, '--profile');
|
|
248
|
-
if (profile) {
|
|
249
|
-
out.profile = profile.value;
|
|
250
|
-
i += profile.skip;
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
174
|
const db = readValueOption(arg, argv, i, '--db');
|
|
254
175
|
if (db) {
|
|
255
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
|
+
}
|