birc-generator 0.9.1 → 1.0.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/PROJECT.md +23 -4
- package/README.md +13 -11
- package/bin/birc.js +10 -4
- package/bin/cli-util.js +30 -2
- package/lib/create.js +150 -0
- package/lib/features.js +558 -0
- package/lib/make.js +658 -0
- package/lib/project.js +234 -0
- package/package.json +5 -3
- package/plopfile.js +60 -1340
- package/project-docs/PROJECT.md.hbs +13 -0
- package/templates/aop/OperationLogAspect.hbs +10 -3
- package/templates/auth/AuthController.hbs +73 -0
- package/templates/auth/AuthFlowTest.hbs +112 -0
- package/templates/auth/AuthSecurityCustomizer.hbs +38 -0
- package/templates/auth/AuthUser.hbs +54 -0
- package/templates/auth/AuthUserDAO.hbs +9 -0
- package/templates/auth/JpaUserDetailsService.hbs +47 -0
- package/templates/auth/JwtAuthenticationFilter.hbs +50 -0
- package/templates/auth/JwtProperties.hbs +35 -0
- package/templates/auth/JwtSecretEnvTest.hbs +68 -0
- package/templates/auth/JwtSecretEnvironmentPostProcessor.hbs +102 -0
- package/templates/auth/JwtService.hbs +60 -0
- package/templates/auth/LoginFailedException.hbs +27 -0
- package/templates/auth/LoginRequest.hbs +16 -0
- package/templates/auth/V1__create_auth_users_table.sql.hbs +13 -0
- package/templates/auth/application-yml-block.hbs +7 -0
- package/templates/auth/build-gradle-dep.hbs +4 -0
- package/templates/auth/spring.factories.hbs +1 -0
- package/templates/base/CorsTest.java.hbs +93 -0
- package/templates/base/README.md.hbs +6 -0
- package/templates/base/application-test.yml.hbs +3 -0
- package/templates/base/application.yml.hbs +12 -0
- package/templates/clockin/ClockInApiException.hbs +37 -0
- package/templates/clockin/ClockInApiResponse.hbs +14 -0
- package/templates/clockin/ClockInClient.hbs +251 -0
- package/templates/clockin/ClockInClientConfig.hbs +41 -0
- package/templates/clockin/ClockInController.hbs +77 -0
- package/templates/clockin/ClockInPage.hbs +11 -0
- package/templates/clockin/ClockInProperties.hbs +34 -0
- package/templates/clockin/ClockInRecord.hbs +20 -0
- package/templates/clockin/MemberImage.hbs +7 -0
- package/templates/clockin/OnDutyWeek.hbs +26 -0
- package/templates/clockin/UnclockedMember.hbs +7 -0
- package/templates/clockin/UserPermission.hbs +11 -0
- package/templates/clockin/application-yml-block.hbs +10 -0
- package/templates/docker/docker-compose.prod.yml.hbs +5 -0
- package/templates/docker/docker-compose.yml.hbs +7 -1
- package/templates/docker/env.example.hbs +15 -0
- package/templates/file-upload/FileExtensionUtils.hbs +45 -0
- package/templates/file-upload/FileExtensionUtilsTest.hbs +48 -0
- package/templates/file-upload/FileStorageServiceImpl.hbs +6 -0
- package/templates/multi-module/config/SecurityConfig.java.hbs +123 -8
- package/templates/multi-module/config/SecurityCustomizer.java.hbs +24 -0
- package/templates/openapi/ApiDocsAccessTest.hbs +60 -0
- package/templates/openapi/application-yml-block.hbs +8 -0
- package/test.md +6 -3
- package/versions.js +1 -0
package/plopfile.js
CHANGED
|
@@ -1,1060 +1,46 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
4
|
-
const
|
|
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
|
-
if (config.projectNameKebab) {
|
|
45
|
-
return config.projectNameKebab;
|
|
46
|
-
}
|
|
47
|
-
const modulesDir = path.join(projectRoot(), 'modules');
|
|
48
|
-
if (fs.existsSync(modulesDir)) {
|
|
49
|
-
const match = fs.readdirSync(modulesDir).find((name) => name.endsWith('-database-config'));
|
|
50
|
-
if (match) {
|
|
51
|
-
return match.replace(/-database-config$/, '');
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return path.basename(projectRoot());
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function persistenceLayout(basePackage, { multiModule, projectNameKebab, srcPath }) {
|
|
58
|
-
const basePackagePath = basePackage.replace(/\./g, '/');
|
|
59
|
-
const src = srcPath || 'src/main/java';
|
|
60
|
-
if (multiModule) {
|
|
61
|
-
const dbRoot = `modules/${projectNameKebab}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
|
|
62
|
-
return {
|
|
63
|
-
entityPackage: `${basePackage}.databaseconfig.entity`,
|
|
64
|
-
daoPackage: `${basePackage}.databaseconfig.dao`,
|
|
65
|
-
entityRoot: `${dbRoot}/entity`,
|
|
66
|
-
daoRoot: `${dbRoot}/dao`
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
return {
|
|
70
|
-
entityPackage: `${basePackage}.entity`,
|
|
71
|
-
daoPackage: `${basePackage}.dao`,
|
|
72
|
-
entityRoot: `${src}/${basePackagePath}/entity`,
|
|
73
|
-
daoRoot: `${src}/${basePackagePath}/dao`
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function resolvePersistence(config) {
|
|
78
|
-
if (config.entityPath && config.daoPath && config.entityPackage && config.daoPackage) {
|
|
79
|
-
return {
|
|
80
|
-
entityPackage: config.entityPackage,
|
|
81
|
-
daoPackage: config.daoPackage,
|
|
82
|
-
entityRoot: config.entityPath,
|
|
83
|
-
daoRoot: config.daoPath
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
return persistenceLayout(config.basePackage, {
|
|
87
|
-
multiModule: detectMultiModule(config),
|
|
88
|
-
projectNameKebab: inferProjectNameKebab(config),
|
|
89
|
-
srcPath: config.srcPath
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function toKebab(name) {
|
|
94
|
-
return String(name || '')
|
|
95
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
96
|
-
.replace(/[\s_]+/g, '-')
|
|
97
|
-
.replace(/^-+|-+$/g, '')
|
|
98
|
-
.toLowerCase();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const JAVA_RESERVED_WORDS = new Set([
|
|
102
|
-
'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class',
|
|
103
|
-
'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'exports', 'extends',
|
|
104
|
-
'false', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import',
|
|
105
|
-
'instanceof', 'int', 'interface', 'long', 'module', 'native', 'new', 'non-sealed',
|
|
106
|
-
'null', 'open', 'opens', 'package', 'permits', 'private', 'protected', 'provides',
|
|
107
|
-
'public', 'record', 'requires', 'return', 'sealed', 'short', 'static', 'strictfp',
|
|
108
|
-
'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'to', 'transient',
|
|
109
|
-
'transitive', 'true', 'try', 'uses', 'var', 'void', 'volatile', 'while', 'with',
|
|
110
|
-
'yield', '_'
|
|
111
|
-
]);
|
|
112
|
-
|
|
113
|
-
const CREATE_DEFAULTS = Object.freeze({
|
|
114
|
-
projectName: 'Practice',
|
|
115
|
-
basePackage: 'tw.edu.ntub.birc.practice'
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
function validateProjectName(value) {
|
|
119
|
-
const projectName = toKebab(value);
|
|
120
|
-
if (!projectName) {
|
|
121
|
-
return '專案名稱轉成 kebab-case 後不可為空。';
|
|
122
|
-
}
|
|
123
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(projectName)) {
|
|
124
|
-
return '專案名稱只能包含英文字母、數字、空白、底線或連字號。';
|
|
125
|
-
}
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function validateJavaPackage(value) {
|
|
130
|
-
const packageName = String(value || '').trim();
|
|
131
|
-
if (!packageName) {
|
|
132
|
-
return 'Base package 不可為空。';
|
|
133
|
-
}
|
|
134
|
-
const segments = packageName.split('.');
|
|
135
|
-
if (segments.some((segment) => !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(segment))) {
|
|
136
|
-
return 'Base package 的每一段都必須是合法 Java identifier。';
|
|
137
|
-
}
|
|
138
|
-
if (segments.some((segment) => JAVA_RESERVED_WORDS.has(segment))) {
|
|
139
|
-
return 'Base package 不可使用 Java 保留字。';
|
|
140
|
-
}
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function validatePascalCase(value) {
|
|
145
|
-
return /^[A-Z][A-Za-z0-9]*$/.test(toPascalCaseName(value))
|
|
146
|
-
? true
|
|
147
|
-
: '名稱必須是英文字母開頭,例如 Activity 或 activity。';
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function validateMigrationName(value) {
|
|
151
|
-
const raw = String(value || '').trim();
|
|
152
|
-
if (!raw) return '遷移名稱不可為空。';
|
|
153
|
-
// 白名單擋掉 SQL 特殊字元與路徑字元:名稱會進 Flyway SQL 與檔名。
|
|
154
|
-
return /^[a-zA-Z0-9_]+$/.test(raw)
|
|
155
|
-
? true
|
|
156
|
-
: '遷移名稱只能包含英文字母、數字和底線,例如 create_users_table。';
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const DEFAULT_STRING_COLUMN_LENGTH = 50;
|
|
160
|
-
|
|
161
|
-
function parseFields(fields) {
|
|
162
|
-
if (!fields || !String(fields).trim()) {
|
|
163
|
-
return [];
|
|
164
|
-
}
|
|
165
|
-
return String(fields)
|
|
166
|
-
.split(',')
|
|
167
|
-
.map((f) => {
|
|
168
|
-
const [name, type] = f.split(':');
|
|
169
|
-
const fieldName = (name || '').trim();
|
|
170
|
-
const fieldType = (type || 'String').trim();
|
|
171
|
-
return {
|
|
172
|
-
name: fieldName,
|
|
173
|
-
type: fieldType,
|
|
174
|
-
snakeName: fieldName
|
|
175
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
176
|
-
.replace(/[\s-]+/g, '_')
|
|
177
|
-
.toLowerCase(),
|
|
178
|
-
columnLength: fieldType === 'String' ? DEFAULT_STRING_COLUMN_LENGTH : null
|
|
179
|
-
};
|
|
180
|
-
})
|
|
181
|
-
.filter((f) => f.name);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function isExample(data) {
|
|
185
|
-
return Boolean(data && data.example)
|
|
186
|
-
|| process.env.BIRC_EXAMPLE === '1'
|
|
187
|
-
|| parseFields((data && data.fields) || process.env.BIRC_MAKE_FIELDS).length > 0;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function includeMapperWithEntity(data) {
|
|
191
|
-
return Boolean(data && (data.dto || data.mapper))
|
|
192
|
-
|| process.env.BIRC_MAKE_DTO === '1'
|
|
193
|
-
|| process.env.BIRC_MAKE_MAPPER === '1';
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function includeModelMigration(data) {
|
|
197
|
-
return Boolean(data && data.migration) || process.env.BIRC_MAKE_MIGRATION === '1';
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function includeModelSeed(data) {
|
|
201
|
-
return Boolean(data && data.seed) || process.env.BIRC_MAKE_SEED === '1';
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function skipExistingMakeFiles(data) {
|
|
205
|
-
return includeModelMigration(data) || includeModelSeed(data);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function includeModelController(data) {
|
|
209
|
-
return Boolean(data && data.controller) || process.env.BIRC_MAKE_CONTROLLER === '1';
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function includeSoftDelete(data) {
|
|
213
|
-
return Boolean(data && data.softDelete) || process.env.BIRC_MAKE_SOFT_DELETE === '1';
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function toSnakeCase(text) {
|
|
217
|
-
return String(text || '')
|
|
218
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
219
|
-
.replace(/[\s-]+/g, '_')
|
|
220
|
-
.toLowerCase();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function tableNameFromEntity(entityName) {
|
|
224
|
-
return toSnakeCase(entityName);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const JAVA_SQL_TYPES = Object.freeze({
|
|
228
|
-
String: `VARCHAR(${DEFAULT_STRING_COLUMN_LENGTH}) NULL`,
|
|
229
|
-
Integer: 'INT NULL',
|
|
230
|
-
int: 'INT NULL',
|
|
231
|
-
Long: 'BIGINT NULL',
|
|
232
|
-
long: 'BIGINT NULL',
|
|
233
|
-
Boolean: 'TINYINT(1) NULL',
|
|
234
|
-
boolean: 'TINYINT(1) NULL',
|
|
235
|
-
BigDecimal: 'DECIMAL(19, 2) NULL',
|
|
236
|
-
LocalDate: 'DATE NULL',
|
|
237
|
-
LocalDateTime: 'TIMESTAMP NULL',
|
|
238
|
-
Instant: 'TIMESTAMP NULL',
|
|
239
|
-
Double: 'DOUBLE NULL',
|
|
240
|
-
double: 'DOUBLE NULL',
|
|
241
|
-
Float: 'FLOAT NULL',
|
|
242
|
-
float: 'FLOAT NULL',
|
|
243
|
-
Short: 'SMALLINT NULL',
|
|
244
|
-
short: 'SMALLINT NULL',
|
|
245
|
-
Byte: 'TINYINT NULL',
|
|
246
|
-
byte: 'TINYINT NULL'
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
function sqlTypeForColumn(javaType, columnName) {
|
|
250
|
-
if (columnName === 'created_at') {
|
|
251
|
-
return 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP';
|
|
252
|
-
}
|
|
253
|
-
if (columnName === 'updated_at') {
|
|
254
|
-
return 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP';
|
|
255
|
-
}
|
|
256
|
-
if (columnName === 'deleted_at') {
|
|
257
|
-
return 'TIMESTAMP NULL';
|
|
258
|
-
}
|
|
259
|
-
return JAVA_SQL_TYPES[javaType] || null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function fieldsToMigrationColumns(fields, options) {
|
|
263
|
-
const withTimestamps = Boolean(options && options.withTimestamps);
|
|
264
|
-
const withSoftDelete = Boolean(options && options.withSoftDelete);
|
|
265
|
-
const columns = [];
|
|
266
|
-
const seen = new Set();
|
|
267
|
-
|
|
268
|
-
function addColumn(name, javaType) {
|
|
269
|
-
const columnName = sanitizeMigrationToken(name);
|
|
270
|
-
if (!columnName || columnName === 'id' || seen.has(columnName)) {
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
const sqlType = sqlTypeForColumn(javaType, columnName);
|
|
274
|
-
if (!sqlType) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
seen.add(columnName);
|
|
278
|
-
columns.push({ name: columnName, sqlType });
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
for (const field of fields || []) {
|
|
282
|
-
addColumn(field.snakeName || toSnakeCase(field.name), field.type);
|
|
283
|
-
}
|
|
284
|
-
if (withTimestamps) {
|
|
285
|
-
addColumn('created_at', 'LocalDateTime');
|
|
286
|
-
addColumn('updated_at', 'LocalDateTime');
|
|
287
|
-
}
|
|
288
|
-
if (withSoftDelete) {
|
|
289
|
-
addColumn('deleted_at', 'LocalDateTime');
|
|
290
|
-
}
|
|
291
|
-
return columns;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function listJavaFiles(dir, acc = []) {
|
|
295
|
-
if (!fs.existsSync(dir)) {
|
|
296
|
-
return acc;
|
|
297
|
-
}
|
|
298
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
299
|
-
const full = path.join(dir, entry.name);
|
|
300
|
-
if (entry.isDirectory()) {
|
|
301
|
-
listJavaFiles(full, acc);
|
|
302
|
-
} else if (entry.isFile() && entry.name.endsWith('.java')) {
|
|
303
|
-
acc.push(full);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return acc;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function parseEntityColumns(source) {
|
|
310
|
-
const fields = [];
|
|
311
|
-
const fieldRe =
|
|
312
|
-
/((?:@\w+(?:\s*\([^)]*\))?\s*)*)private\s+([A-Za-z][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g;
|
|
313
|
-
let match = fieldRe.exec(source);
|
|
314
|
-
while (match) {
|
|
315
|
-
const annotations = match[1] || '';
|
|
316
|
-
const type = match[2];
|
|
317
|
-
const name = match[3];
|
|
318
|
-
const columnAttr = annotations.match(/@Column\s*\(([^)]*)\)/);
|
|
319
|
-
let snakeName = toSnakeCase(name);
|
|
320
|
-
if (columnAttr) {
|
|
321
|
-
const named = columnAttr[1].match(/name\s*=\s*"([^"]+)"/);
|
|
322
|
-
if (named) {
|
|
323
|
-
snakeName = named[1];
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
fields.push({ name, type, snakeName });
|
|
327
|
-
match = fieldRe.exec(source);
|
|
328
|
-
}
|
|
329
|
-
return {
|
|
330
|
-
fields,
|
|
331
|
-
withSoftDelete: /@SoftDelete\b/.test(source)
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function findEntitySourceForTable(tableName, config) {
|
|
336
|
-
if (!tableName) {
|
|
337
|
-
return null;
|
|
338
|
-
}
|
|
339
|
-
const persistence = resolvePersistence(config);
|
|
340
|
-
const entityDir = path.join(projectRoot(), persistence.entityRoot);
|
|
341
|
-
const files = listJavaFiles(entityDir);
|
|
342
|
-
const escaped = String(tableName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
343
|
-
const tableRe = new RegExp(`@Table\\s*\\([^)]*name\\s*=\\s*"${escaped}"`, 'i');
|
|
344
|
-
for (const file of files) {
|
|
345
|
-
const source = fs.readFileSync(file, 'utf8');
|
|
346
|
-
if (tableRe.test(source)) {
|
|
347
|
-
return source;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
const className = toPascalCaseName(String(tableName).toLowerCase());
|
|
351
|
-
if (!className) {
|
|
352
|
-
return null;
|
|
353
|
-
}
|
|
354
|
-
const match = files.find((file) => path.basename(file, '.java') === className);
|
|
355
|
-
if (!match) {
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
return fs.readFileSync(match, 'utf8');
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function javaLiteralForSeed(javaType) {
|
|
362
|
-
if (javaType === 'String') {
|
|
363
|
-
return '"example"';
|
|
364
|
-
}
|
|
365
|
-
if (javaType === 'LocalDate') {
|
|
366
|
-
return 'LocalDate.parse("2020-01-01")';
|
|
367
|
-
}
|
|
368
|
-
if (javaType === 'LocalDateTime') {
|
|
369
|
-
return 'LocalDateTime.parse("2020-01-01T00:00:00")';
|
|
370
|
-
}
|
|
371
|
-
if (javaType === 'Instant') {
|
|
372
|
-
return 'Instant.parse("2020-01-01T00:00:00Z")';
|
|
373
|
-
}
|
|
374
|
-
if (javaType === 'BigDecimal') {
|
|
375
|
-
return 'new BigDecimal("0")';
|
|
376
|
-
}
|
|
377
|
-
if (javaType === 'Boolean' || javaType === 'boolean') {
|
|
378
|
-
return 'false';
|
|
379
|
-
}
|
|
380
|
-
if (javaType === 'Long' || javaType === 'long') {
|
|
381
|
-
return '0L';
|
|
382
|
-
}
|
|
383
|
-
if (javaType === 'Integer' || javaType === 'int') {
|
|
384
|
-
return '0';
|
|
385
|
-
}
|
|
386
|
-
if (javaType === 'Short' || javaType === 'short') {
|
|
387
|
-
return '(short) 0';
|
|
388
|
-
}
|
|
389
|
-
if (javaType === 'Byte' || javaType === 'byte') {
|
|
390
|
-
return '(byte) 0';
|
|
391
|
-
}
|
|
392
|
-
if (javaType === 'Double' || javaType === 'double') {
|
|
393
|
-
return '0D';
|
|
394
|
-
}
|
|
395
|
-
if (javaType === 'Float' || javaType === 'float') {
|
|
396
|
-
return '0F';
|
|
397
|
-
}
|
|
398
|
-
if (JAVA_SQL_TYPES[javaType]) {
|
|
399
|
-
return '0';
|
|
400
|
-
}
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const SKIP_SEED_FIELDS = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt', 'created_at', 'updated_at', 'deleted_at']);
|
|
405
|
-
|
|
406
|
-
function fieldsToSeedFields(fields) {
|
|
407
|
-
const result = [];
|
|
408
|
-
const seen = new Set();
|
|
409
|
-
|
|
410
|
-
for (const field of fields || []) {
|
|
411
|
-
const name = String(field.name || '').trim();
|
|
412
|
-
const snakeName = field.snakeName || toSnakeCase(name);
|
|
413
|
-
if (!name || SKIP_SEED_FIELDS.has(name) || SKIP_SEED_FIELDS.has(snakeName) || seen.has(name)) {
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const literal = javaLiteralForSeed(field.type);
|
|
417
|
-
if (!literal) {
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
seen.add(name);
|
|
421
|
-
result.push({
|
|
422
|
-
name,
|
|
423
|
-
type: field.type,
|
|
424
|
-
setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}`,
|
|
425
|
-
literal
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
return result;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function resolveSeedFields(data, tableName, config) {
|
|
432
|
-
const explicitFields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
|
|
433
|
-
if (explicitFields.length > 0) {
|
|
434
|
-
return fieldsToSeedFields(explicitFields);
|
|
435
|
-
}
|
|
436
|
-
const source = findEntitySourceForTable(tableName, config);
|
|
437
|
-
if (source) {
|
|
438
|
-
return fieldsToSeedFields(parseEntityColumns(source).fields);
|
|
439
|
-
}
|
|
440
|
-
if (isExample(data)) {
|
|
441
|
-
return fieldsToSeedFields([{ name: 'name', type: 'String', snakeName: 'name' }]);
|
|
442
|
-
}
|
|
443
|
-
return [];
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function entityNameForSeeder(data) {
|
|
447
|
-
const raw = toPascalCaseName((data && data.entityName) || '');
|
|
448
|
-
return raw.replace(/Seeder$/, '') || raw;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function resolveCreateTableColumns(data, tableName, config) {
|
|
452
|
-
const example = isExample(data);
|
|
453
|
-
const flaggedSoftDelete = includeSoftDelete(data);
|
|
454
|
-
const explicitFields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
|
|
455
|
-
if (explicitFields.length > 0) {
|
|
456
|
-
return fieldsToMigrationColumns(explicitFields, {
|
|
457
|
-
withTimestamps: example,
|
|
458
|
-
withSoftDelete: flaggedSoftDelete
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
const source = findEntitySourceForTable(tableName, config);
|
|
462
|
-
if (source) {
|
|
463
|
-
const entity = parseEntityColumns(source);
|
|
464
|
-
return fieldsToMigrationColumns(entity.fields, {
|
|
465
|
-
withTimestamps: false,
|
|
466
|
-
withSoftDelete: flaggedSoftDelete || entity.withSoftDelete
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
if (example) {
|
|
470
|
-
return fieldsToMigrationColumns(
|
|
471
|
-
[{ name: 'name', type: 'String', snakeName: 'name' }],
|
|
472
|
-
{ withTimestamps: true, withSoftDelete: flaggedSoftDelete }
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
return fieldsToMigrationColumns([], {
|
|
476
|
-
withTimestamps: false,
|
|
477
|
-
withSoftDelete: flaggedSoftDelete
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function fieldImports(fields) {
|
|
482
|
-
const types = new Set((fields || []).map((field) => field.type));
|
|
483
|
-
const imports = [];
|
|
484
|
-
if (types.has('LocalDateTime')) imports.push('java.time.LocalDateTime');
|
|
485
|
-
if (types.has('LocalDate')) imports.push('java.time.LocalDate');
|
|
486
|
-
if (types.has('Instant')) imports.push('java.time.Instant');
|
|
487
|
-
if (types.has('BigDecimal')) imports.push('java.math.BigDecimal');
|
|
488
|
-
return imports;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function resolveMakeFields(data) {
|
|
492
|
-
const fields = parseFields(data.fields || process.env.BIRC_MAKE_FIELDS);
|
|
493
|
-
if (fields.length > 0) {
|
|
494
|
-
return fields;
|
|
495
|
-
}
|
|
496
|
-
if (isExample(data)) {
|
|
497
|
-
return [{
|
|
498
|
-
name: 'name',
|
|
499
|
-
type: 'String',
|
|
500
|
-
snakeName: 'name',
|
|
501
|
-
columnLength: DEFAULT_STRING_COLUMN_LENGTH
|
|
502
|
-
}];
|
|
503
|
-
}
|
|
504
|
-
return [];
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// 最後防線:prompt 驗證被繞過時(CLI 直呼、程式化呼叫),輸出值也不得
|
|
508
|
-
// 帶 SQL 特殊字元或路徑字元——tableName/columnName 進 Flyway SQL,
|
|
509
|
-
// description 同時進檔名。
|
|
510
|
-
function sanitizeMigrationToken(value) {
|
|
511
|
-
return String(value || '').replace(/[^a-zA-Z0-9_]/g, '');
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function parseMigrationName(name) {
|
|
515
|
-
const raw = String(name || '').trim();
|
|
516
|
-
let match = raw.match(/^create_(.+)_table$/i);
|
|
517
|
-
if (match) {
|
|
518
|
-
return {
|
|
519
|
-
kind: 'create',
|
|
520
|
-
tableName: sanitizeMigrationToken(match[1]),
|
|
521
|
-
columnName: null,
|
|
522
|
-
description: sanitizeMigrationToken(raw) || 'migration'
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
match = raw.match(/^add_(.+)_to_(.+)_table$/i);
|
|
526
|
-
if (match) {
|
|
527
|
-
return {
|
|
528
|
-
kind: 'add',
|
|
529
|
-
columnName: sanitizeMigrationToken(match[1]),
|
|
530
|
-
tableName: sanitizeMigrationToken(match[2]),
|
|
531
|
-
description: sanitizeMigrationToken(raw) || 'migration'
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
match = raw.match(/^remove_(.+)_from_(.+)_table$/i);
|
|
535
|
-
if (match) {
|
|
536
|
-
return {
|
|
537
|
-
kind: 'remove',
|
|
538
|
-
columnName: sanitizeMigrationToken(match[1]),
|
|
539
|
-
tableName: sanitizeMigrationToken(match[2]),
|
|
540
|
-
description: sanitizeMigrationToken(raw) || 'migration'
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
const customDescription = sanitizeMigrationToken(raw);
|
|
544
|
-
return { kind: 'custom', tableName: null, columnName: null, description: customDescription || 'migration' };
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function nextMigrationVersion(migrationDir) {
|
|
548
|
-
if (!fs.existsSync(migrationDir)) {
|
|
549
|
-
return 1;
|
|
550
|
-
}
|
|
551
|
-
const versions = fs
|
|
552
|
-
.readdirSync(migrationDir)
|
|
553
|
-
.filter((file) => file.startsWith('V') && file.endsWith('.sql'))
|
|
554
|
-
.map((file) => {
|
|
555
|
-
const match = file.match(/^V(\d+)__/);
|
|
556
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
557
|
-
});
|
|
558
|
-
return versions.length ? Math.max(...versions) + 1 : 1;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function migrationTemplate(kind) {
|
|
562
|
-
if (kind === 'create') return 'templates/migration/create-table.hbs';
|
|
563
|
-
if (kind === 'add') return 'templates/migration/add-column.hbs';
|
|
564
|
-
if (kind === 'remove') return 'templates/migration/remove-column.hbs';
|
|
565
|
-
return 'templates/migration/custom.hbs';
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function makeContext(data) {
|
|
569
|
-
if (data && data.entityName) {
|
|
570
|
-
data.entityName = toPascalCaseName(data.entityName);
|
|
571
|
-
}
|
|
572
|
-
const config = loadProjectConfig();
|
|
573
|
-
const persistence = resolvePersistence(config);
|
|
574
|
-
const basePackage = config.basePackage;
|
|
575
|
-
const srcPath = config.srcPath || 'src/main/java';
|
|
576
|
-
const root = `${srcPath}/${basePackage.replace(/\./g, '/')}`;
|
|
577
|
-
const fields = resolveMakeFields(data);
|
|
578
|
-
const example = isExample(data);
|
|
579
|
-
const extraImports = fieldImports(fields);
|
|
580
|
-
const entityImports = extraImports.includes('java.time.LocalDateTime') || !example
|
|
581
|
-
? extraImports
|
|
582
|
-
: extraImports.concat('java.time.LocalDateTime');
|
|
583
|
-
return {
|
|
584
|
-
config,
|
|
585
|
-
persistence,
|
|
586
|
-
root,
|
|
587
|
-
gradlePath: config.buildGradlePath || 'build.gradle',
|
|
588
|
-
templateData: {
|
|
589
|
-
...VERSIONS,
|
|
590
|
-
basePackage,
|
|
591
|
-
makeFields: fields,
|
|
592
|
-
extraImports,
|
|
593
|
-
entityImports,
|
|
594
|
-
withExample: example,
|
|
595
|
-
withSoftDelete: includeSoftDelete(data),
|
|
596
|
-
entityPackage: persistence.entityPackage,
|
|
597
|
-
daoPackage: persistence.daoPackage
|
|
598
|
-
}
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function exceptionClassName(name) {
|
|
603
|
-
const base = toPascalCaseName(name);
|
|
604
|
-
return /Exception$/i.test(base) ? base.replace(/exception$/i, 'Exception') : `${base}Exception`;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function exceptionSkeletonActions(root, templateData) {
|
|
608
|
-
return [
|
|
609
|
-
{ type: 'add', path: `${root}/exception/ProjectException.java`, templateFile: 'templates/exception/ProjectException.hbs', data: templateData },
|
|
610
|
-
{ type: 'add', path: `${root}/exception/NotFoundException.java`, templateFile: 'templates/exception/NotFoundException.hbs', data: templateData },
|
|
611
|
-
{ type: 'add', path: `${root}/controller/ExceptionHandleController.java`, templateFile: 'templates/exception/ExceptionHandleController.hbs', data: templateData },
|
|
612
|
-
{ type: 'add', path: `${root}/web/Result.java`, templateFile: 'templates/base/Result.hbs', data: templateData }
|
|
613
|
-
];
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
function serviceSkeletonActions(root, templateData) {
|
|
617
|
-
return [
|
|
618
|
-
{ type: 'add', path: `${root}/mapper/EntityMapper.java`, templateFile: 'templates/base/EntityMapper.hbs', data: templateData },
|
|
619
|
-
{ type: 'add', path: `${root}/service/BaseService.java`, templateFile: 'templates/base/BaseService.hbs', data: templateData },
|
|
620
|
-
{ type: 'add', path: `${root}/service/impl/BaseServiceImpl.java`, templateFile: 'templates/base/BaseServiceImpl.hbs', data: templateData }
|
|
621
|
-
];
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function seederSkeletonActions(root, templateData, options) {
|
|
625
|
-
const skipIfExists = Boolean(options && options.skipIfExists);
|
|
626
|
-
return [
|
|
627
|
-
{ type: 'add', path: `${root}/seeder/Seeder.java`, templateFile: 'templates/seeder/Seeder.hbs', data: templateData, skipIfExists },
|
|
628
|
-
{ type: 'add', path: `${root}/seeder/SeedRunner.java`, templateFile: 'templates/seeder/SeedRunner.hbs', data: templateData, skipIfExists }
|
|
629
|
-
];
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function uniqueFeatureKeys(features) {
|
|
633
|
-
return [...new Set((features || []).filter((featureKey) => FEATURES[featureKey]))];
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const YML_ANCHOR = '# birc-generator:config-anchor';
|
|
637
|
-
const GRADLE_ANCHOR = '// birc-generator:dependency-anchor';
|
|
638
|
-
const GRADLE_PLUGIN_ANCHOR = '// birc-generator:plugin-anchor';
|
|
639
|
-
const GRADLE_ALLPROJECTS_ANCHOR = '// birc-generator:allprojects-anchor';
|
|
640
|
-
|
|
641
|
-
function requireGradleAnchor(errors, featureKey, destBase, gradlePath, anchor) {
|
|
642
|
-
const target = path.resolve(destBase, gradlePath);
|
|
643
|
-
if (!fs.existsSync(target)) {
|
|
644
|
-
errors.push(`${featureKey}: 找不到 ${gradlePath}`);
|
|
645
|
-
} else if (!fs.readFileSync(target, 'utf8').includes(anchor)) {
|
|
646
|
-
errors.push(`${featureKey}: ${gradlePath} 沒有 ${anchor}`);
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function gradleAnchorPattern(anchor) {
|
|
651
|
-
return new RegExp(`${anchor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function isAddForce(data) {
|
|
655
|
-
return Boolean(data && data.force) || process.env.BIRC_ADD_FORCE === '1';
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
function isAddSync(data) {
|
|
659
|
-
return Boolean(data && data.sync) || process.env.BIRC_ADD_SYNC === '1';
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function preflightAdd(featureKeys, { destBase, ymlPath, gradlePath }) {
|
|
663
|
-
const errors = [];
|
|
664
|
-
for (const featureKey of featureKeys) {
|
|
665
|
-
const feature = FEATURES[featureKey];
|
|
666
|
-
if (feature.ymlBlock) {
|
|
667
|
-
const target = path.resolve(destBase, ymlPath);
|
|
668
|
-
if (!fs.existsSync(target)) {
|
|
669
|
-
errors.push(`${featureKey}: 找不到 ${ymlPath}`);
|
|
670
|
-
} else if (!fs.readFileSync(target, 'utf8').includes(YML_ANCHOR)) {
|
|
671
|
-
errors.push(`${featureKey}: ${ymlPath} 沒有 ${YML_ANCHOR}`);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
if (feature.gradleDep) {
|
|
675
|
-
requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ANCHOR);
|
|
676
|
-
}
|
|
677
|
-
if (feature.gradlePlugin) {
|
|
678
|
-
requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_PLUGIN_ANCHOR);
|
|
679
|
-
}
|
|
680
|
-
if (feature.gradleAllprojects) {
|
|
681
|
-
requireGradleAnchor(errors, featureKey, destBase, gradlePath, GRADLE_ALLPROJECTS_ANCHOR);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
if (errors.length > 0) {
|
|
685
|
-
throw new Error(`add 預檢查失敗,沒有寫任何檔:\n${errors.map((line) => ` ${line}`).join('\n')}`);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function buildAddActions(data) {
|
|
690
|
-
const force = isAddForce(data);
|
|
691
|
-
const syncAll = isAddSync(data);
|
|
692
|
-
const config = loadProjectConfig();
|
|
693
|
-
const installed = new Set(config.features || []);
|
|
694
|
-
const selected = syncAll
|
|
695
|
-
? uniqueFeatureKeys(config.features)
|
|
696
|
-
: uniqueFeatureKeys(data.features);
|
|
697
|
-
const featuresToAdd = selected.filter((featureKey) => force || syncAll || !installed.has(featureKey));
|
|
698
|
-
if (featuresToAdd.length === 0) {
|
|
699
|
-
return [];
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const basePackage = config.basePackage;
|
|
703
|
-
const basePackagePath = basePackage.replace(/\./g, '/');
|
|
704
|
-
const srcPath = config.srcPath || 'src/main/java';
|
|
705
|
-
const resourcesPath = config.resourcesPath || 'src/main/resources';
|
|
706
|
-
const ymlPath = config.applicationYmlPath || `${resourcesPath}/application.yml`;
|
|
707
|
-
const gradlePath = config.buildGradlePath || 'build.gradle';
|
|
708
|
-
const root = `${srcPath}/${basePackagePath}`;
|
|
709
|
-
const destBase = projectRoot();
|
|
710
|
-
|
|
711
|
-
preflightAdd(featuresToAdd, { destBase, ymlPath, gradlePath });
|
|
712
|
-
|
|
713
|
-
const templateData = {
|
|
714
|
-
...VERSIONS,
|
|
715
|
-
basePackage,
|
|
716
|
-
projectName: config.projectNameKebab || inferProjectNameKebab(config),
|
|
717
|
-
projectNameKebab: config.projectNameKebab || inferProjectNameKebab(config),
|
|
718
|
-
gitlabProjectPath: data.gitlabProjectPath || '',
|
|
719
|
-
dbRootPassword: randomDbPassword(),
|
|
720
|
-
dbPassword: randomDbPassword()
|
|
721
|
-
};
|
|
722
|
-
|
|
723
|
-
let actions = [];
|
|
724
|
-
for (const featureKey of featuresToAdd) {
|
|
725
|
-
const reinstall = installed.has(featureKey);
|
|
726
|
-
actions = actions.concat(
|
|
727
|
-
featureActions(featureKey, {
|
|
728
|
-
root,
|
|
729
|
-
resourcesRoot: resourcesPath,
|
|
730
|
-
projectRoot: '.',
|
|
731
|
-
ymlPath,
|
|
732
|
-
gradlePath,
|
|
733
|
-
templateData,
|
|
734
|
-
overwriteFiles: force || syncAll,
|
|
735
|
-
skipConfigFragments: reinstall && (force || syncAll)
|
|
736
|
-
})
|
|
737
|
-
);
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
actions.push({ type: 'updateProjectFeatures', features: featuresToAdd });
|
|
741
|
-
return actions;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// 每個 feature 對應:要加哪些檔案、要往 application.yml / build.gradle 的哪個 anchor 插入什麼
|
|
745
|
-
const FEATURES = {
|
|
746
|
-
email: {
|
|
747
|
-
label: 'Email(JavaMailSender + Thymeleaf 模板)',
|
|
748
|
-
files: [
|
|
749
|
-
{ templateFile: 'templates/email/EmailService.hbs', dest: 'service/EmailService.java' },
|
|
750
|
-
{ templateFile: 'templates/email/EmailServiceImpl.hbs', dest: 'service/impl/EmailServiceImpl.java' },
|
|
751
|
-
{ templateFile: 'templates/email/EmailAsyncConfig.hbs', dest: 'config/EmailAsyncConfig.java' }
|
|
752
|
-
],
|
|
753
|
-
resourceFiles: [
|
|
754
|
-
{ templateFile: 'templates/email/sample-template.hbs', dest: 'email/sample.html' }
|
|
755
|
-
],
|
|
756
|
-
ymlBlock: 'templates/email/application-yml-block.hbs',
|
|
757
|
-
gradleDep: 'templates/email/build-gradle-dep.hbs'
|
|
758
|
-
},
|
|
759
|
-
sso: {
|
|
760
|
-
label: 'SSO(校內單一登入,SsoProperties + AutoConfiguration 原始碼)',
|
|
761
|
-
files: [
|
|
762
|
-
{ templateFile: 'templates/sso/SsoProperties.hbs', dest: 'security/SsoProperties.java' },
|
|
763
|
-
{ templateFile: 'templates/sso/SsoAutoConfiguration.hbs', dest: 'config/SsoAutoConfiguration.java' }
|
|
764
|
-
],
|
|
765
|
-
resourceFiles: [],
|
|
766
|
-
ymlBlock: 'templates/sso/application-yml-block.hbs',
|
|
767
|
-
gradleDep: null
|
|
768
|
-
},
|
|
769
|
-
oauth: {
|
|
770
|
-
label: 'OAuth2 登入(預設範例接 Google,其他 provider 自行調整)',
|
|
771
|
-
files: [
|
|
772
|
-
{ templateFile: 'templates/oauth/OAuth2LoginSuccessHandler.hbs', dest: 'config/OAuth2LoginSuccessHandler.java' }
|
|
773
|
-
],
|
|
774
|
-
resourceFiles: [],
|
|
775
|
-
ymlBlock: 'templates/oauth/application-yml-block.hbs',
|
|
776
|
-
gradleDep: 'templates/oauth/build-gradle-dep.hbs'
|
|
777
|
-
},
|
|
778
|
-
scheduling: {
|
|
779
|
-
label: '排程(Scheduling Tasks,@EnableScheduling + 範例 Job)',
|
|
780
|
-
files: [
|
|
781
|
-
{ templateFile: 'templates/scheduling/SchedulingConfig.hbs', dest: 'config/SchedulingConfig.java' },
|
|
782
|
-
{ templateFile: 'templates/scheduling/SampleSchedule.hbs', dest: 'schedule/SampleSchedule.java' }
|
|
783
|
-
],
|
|
784
|
-
resourceFiles: [],
|
|
785
|
-
ymlBlock: 'templates/scheduling/application-yml-block.hbs',
|
|
786
|
-
gradleDep: null
|
|
787
|
-
},
|
|
788
|
-
aop: {
|
|
789
|
-
label: 'AOP 事件記錄(自訂 @OperationLog 註解 + Aspect)',
|
|
790
|
-
files: [
|
|
791
|
-
{ templateFile: 'templates/aop/OperationLog.hbs', dest: 'annotation/OperationLog.java' },
|
|
792
|
-
{ templateFile: 'templates/aop/OperationLogAspect.hbs', dest: 'aspect/OperationLogAspect.java' }
|
|
793
|
-
],
|
|
794
|
-
resourceFiles: [],
|
|
795
|
-
ymlBlock: null,
|
|
796
|
-
gradleDep: 'templates/aop/build-gradle-dep.hbs'
|
|
797
|
-
},
|
|
798
|
-
client: {
|
|
799
|
-
label: 'Client(呼叫外部 API 的 RestClient 封裝)',
|
|
800
|
-
files: [
|
|
801
|
-
{ templateFile: 'templates/client/ExternalApiProperties.hbs', dest: 'config/ExternalApiProperties.java' },
|
|
802
|
-
{ templateFile: 'templates/client/ExternalApiClientConfig.hbs', dest: 'config/ExternalApiClientConfig.java' },
|
|
803
|
-
{ templateFile: 'templates/client/ExternalApiClient.hbs', dest: 'client/ExternalApiClient.java' }
|
|
804
|
-
],
|
|
805
|
-
resourceFiles: [],
|
|
806
|
-
ymlBlock: 'templates/client/application-yml-block.hbs',
|
|
807
|
-
gradleDep: null
|
|
808
|
-
},
|
|
809
|
-
sentry: {
|
|
810
|
-
label: 'Sentry(錯誤追蹤 + 客戶端例外過濾)',
|
|
811
|
-
files: [
|
|
812
|
-
{ templateFile: 'templates/sentry/SentryConfig.hbs', dest: 'config/SentryConfig.java' }
|
|
813
|
-
],
|
|
814
|
-
resourceFiles: [],
|
|
815
|
-
ymlBlock: 'templates/sentry/application-yml-block.hbs',
|
|
816
|
-
gradleDep: 'templates/sentry/build-gradle-dep.hbs'
|
|
817
|
-
},
|
|
818
|
-
log4j2: {
|
|
819
|
-
label: 'Log4j2(主控台 + rolling file + MDC)',
|
|
820
|
-
files: [],
|
|
821
|
-
resourceFiles: [
|
|
822
|
-
{ templateFile: 'templates/log4j2/log4j2-spring.xml.hbs', dest: 'log4j2-spring.xml' }
|
|
823
|
-
],
|
|
824
|
-
ymlBlock: null,
|
|
825
|
-
gradleDep: 'templates/log4j2/build-gradle-dep.hbs'
|
|
826
|
-
},
|
|
827
|
-
openapi: {
|
|
828
|
-
label: 'OpenAPI(springdoc + Swagger UI + Bearer JWT)',
|
|
829
|
-
files: [
|
|
830
|
-
{ templateFile: 'templates/openapi/OpenApiConfig.hbs', dest: 'config/OpenApiConfig.java' }
|
|
831
|
-
],
|
|
832
|
-
resourceFiles: [],
|
|
833
|
-
ymlBlock: null,
|
|
834
|
-
gradleDep: 'templates/openapi/build-gradle-dep.hbs'
|
|
835
|
-
},
|
|
836
|
-
fileUpload: {
|
|
837
|
-
label: '檔案上傳(本機磁碟儲存,可自行改接 S3 / MinIO)',
|
|
838
|
-
files: [
|
|
839
|
-
{ templateFile: 'templates/file-upload/FileStorageProperties.hbs', dest: 'config/FileStorageProperties.java' },
|
|
840
|
-
{ templateFile: 'templates/file-upload/FileStorageService.hbs', dest: 'service/FileStorageService.java' },
|
|
841
|
-
{ templateFile: 'templates/file-upload/FileStorageServiceImpl.hbs', dest: 'service/impl/FileStorageServiceImpl.java' },
|
|
842
|
-
{ templateFile: 'templates/file-upload/FileUploadController.hbs', dest: 'controller/FileUploadController.java' },
|
|
843
|
-
{ templateFile: 'templates/file-upload/FileUtils.hbs', dest: 'util/file/FileUtils.java' },
|
|
844
|
-
{ templateFile: 'templates/file-upload/FileExtensionUtils.hbs', dest: 'util/file/FileExtensionUtils.java' },
|
|
845
|
-
{ templateFile: 'templates/file-upload/EmptyFileException.hbs', dest: 'exception/file/EmptyFileException.java' },
|
|
846
|
-
{ templateFile: 'templates/file-upload/FileExtensionIllegalException.hbs', dest: 'exception/file/FileExtensionIllegalException.java' },
|
|
847
|
-
{ templateFile: 'templates/file-upload/InvalidStoredFileException.hbs', dest: 'exception/file/InvalidStoredFileException.java' },
|
|
848
|
-
{ templateFile: 'templates/file-upload/FileTooLargeException.hbs', dest: 'exception/file/FileTooLargeException.java' }
|
|
849
|
-
],
|
|
850
|
-
resourceFiles: [],
|
|
851
|
-
ymlBlock: 'templates/file-upload/application-yml-block.hbs',
|
|
852
|
-
gradleDep: null
|
|
853
|
-
},
|
|
854
|
-
pagination: {
|
|
855
|
-
label: '分頁查詢(Specification 動態條件 + PageRequest/PageResponse)',
|
|
856
|
-
files: [
|
|
857
|
-
{ templateFile: 'templates/pagination/SearchCriteria.hbs', dest: 'specification/SearchCriteria.java' },
|
|
858
|
-
{ templateFile: 'templates/pagination/GenericSpecification.hbs', dest: 'specification/GenericSpecification.java' },
|
|
859
|
-
{ templateFile: 'templates/pagination/SpecificationSupport.hbs', dest: 'specification/SpecificationSupport.java' },
|
|
860
|
-
{ templateFile: 'templates/pagination/PageRequest.hbs', dest: 'dto/PageRequest.java' },
|
|
861
|
-
{ templateFile: 'templates/pagination/PageResponse.hbs', dest: 'dto/PageResponse.java' },
|
|
862
|
-
{ templateFile: 'templates/pagination/Pager.hbs', dest: 'dto/Pager.java' },
|
|
863
|
-
{ templateFile: 'templates/pagination/PageInfo.hbs', dest: 'dto/PageInfo.java' }
|
|
864
|
-
],
|
|
865
|
-
resourceFiles: [],
|
|
866
|
-
ymlBlock: null,
|
|
867
|
-
gradleDep: null
|
|
868
|
-
},
|
|
869
|
-
validGroup: {
|
|
870
|
-
label: '驗證群組(ValidGroup:Create / Update / Delete / Submit)',
|
|
871
|
-
files: [
|
|
872
|
-
{ templateFile: 'templates/valid-group/ValidGroup.hbs', dest: 'validation/ValidGroup.java' }
|
|
873
|
-
],
|
|
874
|
-
resourceFiles: [],
|
|
875
|
-
ymlBlock: null,
|
|
876
|
-
gradleDep: null
|
|
877
|
-
},
|
|
878
|
-
permission: {
|
|
879
|
-
label: '權限控管(Spring Security + 自訂 @RequirePermission)',
|
|
880
|
-
files: [
|
|
881
|
-
{ templateFile: 'templates/permission/RequirePermission.hbs', dest: 'annotation/RequirePermission.java' },
|
|
882
|
-
{ templateFile: 'templates/permission/PermissionAspect.hbs', dest: 'aspect/PermissionAspect.java' }
|
|
883
|
-
],
|
|
884
|
-
resourceFiles: [],
|
|
885
|
-
ymlBlock: null,
|
|
886
|
-
gradleDep: 'templates/permission/build-gradle-dep.hbs'
|
|
887
|
-
},
|
|
888
|
-
gitlabCi: {
|
|
889
|
-
label: '.gitlab-ci.yml(Harbor build/push + SSH deploy,對齊 teaching-platform)',
|
|
890
|
-
files: [],
|
|
891
|
-
resourceFiles: [],
|
|
892
|
-
rootFiles: [
|
|
893
|
-
{ templateFile: 'templates/gitlab-ci/gitlab-ci.hbs', dest: '.gitlab-ci.yml' }
|
|
894
|
-
],
|
|
895
|
-
ymlBlock: null,
|
|
896
|
-
gradleDep: null
|
|
897
|
-
},
|
|
898
|
-
docker: {
|
|
899
|
-
label: 'Docker(多階段 Dockerfile + compose + .env.example)',
|
|
900
|
-
files: [],
|
|
901
|
-
resourceFiles: [],
|
|
902
|
-
rootFiles: [
|
|
903
|
-
{ templateFile: 'templates/docker/Dockerfile.hbs', dest: 'Dockerfile' },
|
|
904
|
-
{ templateFile: 'templates/docker/docker-compose.yml.hbs', dest: 'docker-compose.yml' },
|
|
905
|
-
{ templateFile: 'templates/docker/docker-compose.prod.yml.hbs', dest: 'docker-compose.prod.yml' },
|
|
906
|
-
{ templateFile: 'templates/docker/env.example.hbs', dest: '.env.example' },
|
|
907
|
-
{ templateFile: 'templates/docker/dockerignore.hbs', dest: '.dockerignore' }
|
|
908
|
-
],
|
|
909
|
-
ymlBlock: null,
|
|
910
|
-
gradleDep: null
|
|
911
|
-
},
|
|
912
|
-
spotless: {
|
|
913
|
-
label: 'Spotless(Eclipse 4.31 格式化,commit 前 ./gradlew spotlessApply)',
|
|
914
|
-
files: [],
|
|
915
|
-
resourceFiles: [],
|
|
916
|
-
rootFiles: [
|
|
917
|
-
{ templateFile: 'templates/spotless/spotless_formatter.xml', dest: 'spotless_formatter.xml' }
|
|
918
|
-
],
|
|
919
|
-
ymlBlock: null,
|
|
920
|
-
gradleDep: null,
|
|
921
|
-
gradlePlugin: 'templates/spotless/build-gradle-plugin.hbs',
|
|
922
|
-
gradleAllprojects: 'templates/spotless/build-gradle-allprojects.hbs'
|
|
923
|
-
}
|
|
924
|
-
};
|
|
925
|
-
|
|
926
|
-
const FEATURE_GROUPS = [
|
|
927
|
-
{
|
|
928
|
-
title: '常用',
|
|
929
|
-
keys: ['docker', 'log4j2', 'openapi', 'spotless', 'validGroup', 'pagination', 'fileUpload', 'email', 'client', 'aop']
|
|
930
|
-
},
|
|
931
|
-
{ title: '登入', keys: ['sso', 'oauth', 'permission'] },
|
|
932
|
-
{ title: '營運', keys: ['gitlabCi', 'sentry', 'scheduling'] }
|
|
933
|
-
];
|
|
934
|
-
const DEFAULT_FEATURES = new Set(['docker', 'log4j2', 'validGroup', 'spotless']);
|
|
935
|
-
|
|
936
|
-
function groupedFeatureChoices({
|
|
937
|
-
installed = new Set(),
|
|
938
|
-
allowInstalled = false,
|
|
939
|
-
withDefaults = false
|
|
940
|
-
} = {}) {
|
|
941
|
-
const used = new Set();
|
|
942
|
-
const choices = [];
|
|
943
|
-
for (const group of FEATURE_GROUPS) {
|
|
944
|
-
const keys = group.keys.filter((key) => FEATURES[key]);
|
|
945
|
-
if (keys.length === 0) continue;
|
|
946
|
-
choices.push({ type: 'separator', line: `── ${group.title} ──` });
|
|
947
|
-
for (const key of keys) {
|
|
948
|
-
used.add(key);
|
|
949
|
-
choices.push({
|
|
950
|
-
name: FEATURES[key].label,
|
|
951
|
-
value: key,
|
|
952
|
-
checked: withDefaults && DEFAULT_FEATURES.has(key),
|
|
953
|
-
disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
const leftover = Object.keys(FEATURES).filter((key) => !used.has(key));
|
|
958
|
-
if (leftover.length > 0) {
|
|
959
|
-
choices.push({ type: 'separator', line: '── 其他 ──' });
|
|
960
|
-
for (const key of leftover) {
|
|
961
|
-
choices.push({
|
|
962
|
-
name: FEATURES[key].label,
|
|
963
|
-
value: key,
|
|
964
|
-
checked: withDefaults && DEFAULT_FEATURES.has(key),
|
|
965
|
-
disabled: !allowInstalled && installed.has(key) ? '已安裝' : false
|
|
966
|
-
});
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
return choices;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// 產生單一 feature 的 plop actions。root / resourcesRoot / ymlPath / gradlePath 都是絕對相對於
|
|
973
|
-
// plop destBasePath 的路徑,create 跟 add 共用同一份邏輯,差別只在 root 前面有沒有專案資料夾。
|
|
974
|
-
function featureActions(featureKey, {
|
|
975
|
-
root,
|
|
976
|
-
resourcesRoot,
|
|
977
|
-
projectRoot,
|
|
978
|
-
ymlPath,
|
|
979
|
-
gradlePath,
|
|
980
|
-
templateData,
|
|
981
|
-
overwriteFiles = false,
|
|
982
|
-
skipConfigFragments = false
|
|
983
|
-
}) {
|
|
984
|
-
const feature = FEATURES[featureKey];
|
|
985
|
-
if (!feature) {
|
|
986
|
-
throw new Error(`未知的 feature: ${featureKey}`);
|
|
987
|
-
}
|
|
988
|
-
const actions = [];
|
|
989
|
-
|
|
990
|
-
for (const f of feature.files) {
|
|
991
|
-
actions.push({
|
|
992
|
-
type: 'add',
|
|
993
|
-
path: `${root}/${f.dest}`,
|
|
994
|
-
templateFile: f.templateFile,
|
|
995
|
-
data: templateData,
|
|
996
|
-
skipIfExists: !overwriteFiles,
|
|
997
|
-
abortOnFail: true
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
for (const f of feature.resourceFiles) {
|
|
1002
|
-
actions.push({
|
|
1003
|
-
type: 'add',
|
|
1004
|
-
path: `${resourcesRoot}/${f.dest}`,
|
|
1005
|
-
templateFile: f.templateFile,
|
|
1006
|
-
data: templateData,
|
|
1007
|
-
skipIfExists: !overwriteFiles,
|
|
1008
|
-
abortOnFail: true
|
|
1009
|
-
});
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
// rootFiles:不屬於任何 package、也不進 resources 的檔案(.gitlab-ci.yml、Dockerfile 之類),
|
|
1013
|
-
// 直接放到專案根目錄(create 是新資料夾根目錄,add 是目前目錄)。
|
|
1014
|
-
if (feature.rootFiles) {
|
|
1015
|
-
for (const f of feature.rootFiles) {
|
|
1016
|
-
actions.push({
|
|
1017
|
-
type: 'add',
|
|
1018
|
-
path: `${projectRoot}/${f.dest}`,
|
|
1019
|
-
templateFile: f.templateFile,
|
|
1020
|
-
data: templateData,
|
|
1021
|
-
skipIfExists: !overwriteFiles,
|
|
1022
|
-
abortOnFail: true
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
if (feature.ymlBlock && !skipConfigFragments) {
|
|
1028
|
-
actions.push({
|
|
1029
|
-
type: 'appendFragment',
|
|
1030
|
-
path: ymlPath,
|
|
1031
|
-
pattern: new RegExp(`${YML_ANCHOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`),
|
|
1032
|
-
templateFile: feature.ymlBlock,
|
|
1033
|
-
data: templateData,
|
|
1034
|
-
abortOnFail: true
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
const gradleFragments = [
|
|
1039
|
-
[feature.gradleDep, GRADLE_ANCHOR],
|
|
1040
|
-
[feature.gradlePlugin, GRADLE_PLUGIN_ANCHOR],
|
|
1041
|
-
[feature.gradleAllprojects, GRADLE_ALLPROJECTS_ANCHOR]
|
|
1042
|
-
];
|
|
1043
|
-
for (const [templateFile, anchor] of gradleFragments) {
|
|
1044
|
-
if (templateFile && !skipConfigFragments) {
|
|
1045
|
-
actions.push({
|
|
1046
|
-
type: 'appendFragment',
|
|
1047
|
-
path: gradlePath,
|
|
1048
|
-
pattern: gradleAnchorPattern(anchor),
|
|
1049
|
-
templateFile,
|
|
1050
|
-
data: templateData,
|
|
1051
|
-
abortOnFail: true
|
|
1052
|
-
});
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
return actions;
|
|
1057
|
-
}
|
|
3
|
+
const { deriveBasePackage, toPascalCaseName, createDestConflict } = require('./bin/cli-util');
|
|
4
|
+
const {
|
|
5
|
+
CREATE_DEFAULTS,
|
|
6
|
+
validateProjectName,
|
|
7
|
+
validateJavaPackage,
|
|
8
|
+
validatePascalCase,
|
|
9
|
+
validateMigrationName,
|
|
10
|
+
loadProjectConfig,
|
|
11
|
+
inferProjectNameKebab,
|
|
12
|
+
fillJwtSecretInEnv,
|
|
13
|
+
randomJwtSecret,
|
|
14
|
+
fragmentMarkers
|
|
15
|
+
} = require('./lib/project');
|
|
16
|
+
const {
|
|
17
|
+
FEATURES,
|
|
18
|
+
FEATURE_GROUPS,
|
|
19
|
+
DEFAULT_FEATURES,
|
|
20
|
+
groupedFeatureChoices,
|
|
21
|
+
isAddForce,
|
|
22
|
+
isAddSync,
|
|
23
|
+
buildAddActions
|
|
24
|
+
} = require('./lib/features');
|
|
25
|
+
const {
|
|
26
|
+
isExample,
|
|
27
|
+
parseMigrationName,
|
|
28
|
+
makeEntityActions,
|
|
29
|
+
makeMapperActions,
|
|
30
|
+
makeServiceActions,
|
|
31
|
+
makeControllerActions,
|
|
32
|
+
makeStackActions,
|
|
33
|
+
makeModelActions,
|
|
34
|
+
makeMigrationActions,
|
|
35
|
+
makeSeederActions,
|
|
36
|
+
appendMakeSqlActions,
|
|
37
|
+
toSnakeCase,
|
|
38
|
+
includeMapperWithEntity,
|
|
39
|
+
skipExistingMakeFiles,
|
|
40
|
+
makeContext,
|
|
41
|
+
exceptionClassName
|
|
42
|
+
} = require('./lib/make');
|
|
43
|
+
const { buildCreateActions } = require('./lib/create');
|
|
1058
44
|
|
|
1059
45
|
module.exports = function (plop) {
|
|
1060
46
|
plop.setHelper('pascalCase', (text) => text.charAt(0).toUpperCase() + text.slice(1));
|
|
@@ -1087,6 +73,12 @@ module.exports = function (plop) {
|
|
|
1087
73
|
return 'dest 可用';
|
|
1088
74
|
});
|
|
1089
75
|
|
|
76
|
+
plop.setActionType('ensureJwtSecret', function (answers, config, plopApi) {
|
|
77
|
+
const destBase = plopApi.getDestBasePath();
|
|
78
|
+
const target = path.resolve(destBase, plopApi.renderString(config.path, answers));
|
|
79
|
+
return fillJwtSecretInEnv(target, randomJwtSecret());
|
|
80
|
+
});
|
|
81
|
+
|
|
1090
82
|
plop.setActionType('copyIfExists', function (answers, config, plopApi) {
|
|
1091
83
|
const destBase = plopApi.getDestBasePath();
|
|
1092
84
|
const from = path.resolve(destBase, plopApi.renderString(config.from, answers));
|
|
@@ -1107,8 +99,9 @@ module.exports = function (plop) {
|
|
|
1107
99
|
const templateData = { ...answers, ...(config.data || {}) };
|
|
1108
100
|
const fragment = plopApi.renderString(fs.readFileSync(templatePath, 'utf8'), templateData).trim();
|
|
1109
101
|
const contents = fs.readFileSync(target, 'utf8');
|
|
102
|
+
const { begin, end } = fragmentMarkers(config.commentToken, config.marker);
|
|
1110
103
|
|
|
1111
|
-
if (contents.includes(fragment)) {
|
|
104
|
+
if (contents.includes(begin) || contents.includes(fragment)) {
|
|
1112
105
|
return `skipped (already installed ${path.relative(destBase, target)})`;
|
|
1113
106
|
}
|
|
1114
107
|
|
|
@@ -1117,7 +110,8 @@ module.exports = function (plop) {
|
|
|
1117
110
|
throw new Error(`找不到插入 anchor:${path.relative(destBase, target)}`);
|
|
1118
111
|
}
|
|
1119
112
|
|
|
1120
|
-
const
|
|
113
|
+
const block = `${begin}\n${fragment}\n${end}`;
|
|
114
|
+
const next = contents.replace(config.pattern, (anchor) => `${anchor}\n${block}\n`);
|
|
1121
115
|
fs.writeFileSync(target, next);
|
|
1122
116
|
return path.relative(destBase, target);
|
|
1123
117
|
});
|
|
@@ -1151,67 +145,14 @@ module.exports = function (plop) {
|
|
|
1151
145
|
when: (answers) => isExample(answers)
|
|
1152
146
|
&& !String((answers && answers.fields) || process.env.BIRC_MAKE_FIELDS || '').trim()
|
|
1153
147
|
};
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
function makeMapperActions(data, options) {
|
|
1165
|
-
const { root, gradlePath, templateData } = makeContext(data);
|
|
1166
|
-
const skipIfExists = Boolean(options && options.skipIfExists);
|
|
1167
|
-
return [
|
|
1168
|
-
{ type: 'add', path: `${root}/dto/{{pascalCase entityName}}CreateRequest.java`, templateFile: 'templates/dto-request.hbs', data: templateData, skipIfExists },
|
|
1169
|
-
{ type: 'add', path: `${root}/dto/{{pascalCase entityName}}Response.java`, templateFile: 'templates/dto-response.hbs', data: templateData, skipIfExists },
|
|
1170
|
-
{ type: 'add', path: `${root}/mapper/{{pascalCase entityName}}Mapper.java`, templateFile: 'templates/mapper.hbs', data: templateData, skipIfExists },
|
|
1171
|
-
{
|
|
1172
|
-
type: 'appendFragment',
|
|
1173
|
-
path: gradlePath,
|
|
1174
|
-
pattern: /\/\/ birc-generator:dependency-anchor\n/,
|
|
1175
|
-
templateFile: 'templates/mapper-gradle-dep.hbs',
|
|
1176
|
-
data: templateData,
|
|
1177
|
-
abortOnFail: true
|
|
1178
|
-
}
|
|
1179
|
-
];
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
function makeServiceActions(data, options) {
|
|
1183
|
-
const { root, templateData } = makeContext(data);
|
|
1184
|
-
const skipIfExists = Boolean(options && options.skipIfExists);
|
|
1185
|
-
return [
|
|
1186
|
-
{ type: 'add', path: `${root}/service/{{pascalCase entityName}}Service.java`, templateFile: 'templates/service.hbs', data: templateData, skipIfExists },
|
|
1187
|
-
{ type: 'add', path: `${root}/service/impl/{{pascalCase entityName}}ServiceImpl.java`, templateFile: 'templates/serviceImpl.hbs', data: templateData, skipIfExists }
|
|
1188
|
-
];
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
function makeControllerActions(data, options) {
|
|
1192
|
-
const { root, templateData } = makeContext(data);
|
|
1193
|
-
const skipIfExists = Boolean(options && options.skipIfExists);
|
|
1194
|
-
return [
|
|
1195
|
-
{
|
|
1196
|
-
type: 'add',
|
|
1197
|
-
path: `${root}/web/Result.java`,
|
|
1198
|
-
templateFile: 'templates/base/Result.hbs',
|
|
1199
|
-
data: templateData,
|
|
1200
|
-
skipIfExists: true
|
|
1201
|
-
},
|
|
1202
|
-
{ type: 'add', path: `${root}/controller/{{pascalCase entityName}}Controller.java`, templateFile: 'templates/controller.hbs', data: templateData, skipIfExists }
|
|
1203
|
-
];
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
function makeStackActions(data) {
|
|
1207
|
-
const skipIfExists = skipExistingMakeFiles(data);
|
|
1208
|
-
return appendMakeSqlActions([
|
|
1209
|
-
...makeEntityActions(data, { skipIfExists }),
|
|
1210
|
-
...makeMapperActions(data, { skipIfExists }),
|
|
1211
|
-
...makeServiceActions(data, { skipIfExists }),
|
|
1212
|
-
...makeControllerActions(data, { skipIfExists })
|
|
1213
|
-
], data);
|
|
1214
|
-
}
|
|
148
|
+
const migrationPrompts = [
|
|
149
|
+
{
|
|
150
|
+
type: 'input',
|
|
151
|
+
name: 'description',
|
|
152
|
+
message: '遷移名稱(例如 create_users_table、add_votes_to_users_table):',
|
|
153
|
+
validate: validateMigrationName
|
|
154
|
+
}
|
|
155
|
+
];
|
|
1215
156
|
|
|
1216
157
|
plop.setGenerator('make', {
|
|
1217
158
|
description: '一次產生 Entity + DAO + Mapper + DTO + Service + Controller;--migration / --seed',
|
|
@@ -1318,7 +259,6 @@ module.exports = function (plop) {
|
|
|
1318
259
|
}
|
|
1319
260
|
});
|
|
1320
261
|
|
|
1321
|
-
// ---------- add:像 php artisan / ng add,往「既有專案」裡加一個功能模組 ----------
|
|
1322
262
|
plop.setGenerator('add', {
|
|
1323
263
|
description: '在既有專案中加入 email / sso / oauth 等功能模組(會改動 application.yml、build.gradle)',
|
|
1324
264
|
prompts: [
|
|
@@ -1358,7 +298,6 @@ module.exports = function (plop) {
|
|
|
1358
298
|
}
|
|
1359
299
|
});
|
|
1360
300
|
|
|
1361
|
-
// ---------- create:像 npm create vite@latest,從零建一個新專案資料夾 ----------
|
|
1362
301
|
plop.setGenerator('create', {
|
|
1363
302
|
description: '建立一個新的 BIRC Spring Boot 多模組專案(互動選擇要不要 email / sso / oauth)',
|
|
1364
303
|
prompts: [
|
|
@@ -1396,132 +335,9 @@ module.exports = function (plop) {
|
|
|
1396
335
|
default: true
|
|
1397
336
|
}
|
|
1398
337
|
],
|
|
1399
|
-
actions:
|
|
1400
|
-
const projectDir = toKebab(data.projectName);
|
|
1401
|
-
const basePackagePath = data.basePackage.replace(/\./g, '/');
|
|
1402
|
-
|
|
1403
|
-
const layout = persistenceLayout(data.basePackage, {
|
|
1404
|
-
multiModule: true,
|
|
1405
|
-
projectNameKebab: projectDir
|
|
1406
|
-
});
|
|
1407
|
-
|
|
1408
|
-
const templateData = {
|
|
1409
|
-
...VERSIONS,
|
|
1410
|
-
basePackage: data.basePackage,
|
|
1411
|
-
projectName: data.projectName,
|
|
1412
|
-
projectNameKebab: projectDir,
|
|
1413
|
-
multiModule: true,
|
|
1414
|
-
entityPackage: layout.entityPackage,
|
|
1415
|
-
daoPackage: layout.daoPackage,
|
|
1416
|
-
entityPath: layout.entityRoot,
|
|
1417
|
-
daoPath: layout.daoRoot,
|
|
1418
|
-
gitlabProjectPath: data.gitlabProjectPath || '',
|
|
1419
|
-
dbRootPassword: randomDbPassword(),
|
|
1420
|
-
dbPassword: randomDbPassword(),
|
|
1421
|
-
hasDocker: (data.features || []).includes('docker')
|
|
1422
|
-
};
|
|
1423
|
-
|
|
1424
|
-
const srcPath = 'src/main/java';
|
|
1425
|
-
const testSrcPath = 'src/test/java';
|
|
1426
|
-
const resourcesPath = 'src/main/resources';
|
|
1427
|
-
const root = `${projectDir}/${srcPath}/${basePackagePath}`;
|
|
1428
|
-
const testRoot = `${projectDir}/${testSrcPath}/${basePackagePath}`;
|
|
1429
|
-
const resourcesRoot = `${projectDir}/${resourcesPath}`;
|
|
1430
|
-
const ymlPath = `${resourcesRoot}/application.yml`;
|
|
1431
|
-
const gradlePath = `${projectDir}/build.gradle`;
|
|
1432
|
-
|
|
1433
|
-
let actions = [
|
|
1434
|
-
{ type: 'assertCreateDestFree', abortOnFail: true },
|
|
1435
|
-
{ type: 'add', path: `${root}/Application.java`, templateFile: 'templates/base/Application.hbs', data: templateData },
|
|
1436
|
-
{ type: 'add', path: `${testRoot}/ApplicationTests.java`, templateFile: 'templates/base/ApplicationTests.java.hbs', data: templateData },
|
|
1437
|
-
{ type: 'add', path: `${projectDir}/src/test/resources/application.yml`, templateFile: 'templates/base/application-test.yml.hbs', data: templateData },
|
|
1438
|
-
{ type: 'add', path: gradlePath, templateFile: 'templates/multi-module/build.gradle.hbs', data: templateData },
|
|
1439
|
-
{ type: 'add', path: `${projectDir}/settings.gradle`, templateFile: 'templates/multi-module/settings.gradle.hbs', data: templateData },
|
|
1440
|
-
{ type: 'add', path: `${resourcesRoot}/application.yml`, templateFile: 'templates/base/application.yml.hbs', data: templateData },
|
|
1441
|
-
{ type: 'add', path: `${projectDir}/.gitignore`, templateFile: 'templates/base/gitignore.hbs', data: templateData },
|
|
1442
|
-
{ type: 'copyFile', from: 'templates/gradle-wrapper/gradlew', path: `${projectDir}/gradlew`, executable: true },
|
|
1443
|
-
{ type: 'copyFile', from: 'templates/gradle-wrapper/gradlew.bat', path: `${projectDir}/gradlew.bat` },
|
|
1444
|
-
{
|
|
1445
|
-
type: 'copyFile',
|
|
1446
|
-
from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar',
|
|
1447
|
-
path: `${projectDir}/gradle/wrapper/gradle-wrapper.jar`
|
|
1448
|
-
},
|
|
1449
|
-
{
|
|
1450
|
-
type: 'copyFile',
|
|
1451
|
-
from: 'templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties',
|
|
1452
|
-
path: `${projectDir}/gradle/wrapper/gradle-wrapper.properties`
|
|
1453
|
-
},
|
|
1454
|
-
{
|
|
1455
|
-
type: 'add',
|
|
1456
|
-
path: `${projectDir}/.bircrc.json`,
|
|
1457
|
-
templateFile: 'templates/base/bircrc.hbs',
|
|
1458
|
-
data: { ...templateData, featuresJson: JSON.stringify(data.features) }
|
|
1459
|
-
}
|
|
1460
|
-
];
|
|
1461
|
-
actions = actions.concat(exceptionSkeletonActions(root, templateData));
|
|
1462
|
-
actions = actions.concat(serviceSkeletonActions(root, templateData));
|
|
1463
|
-
actions = actions.concat(seederSkeletonActions(root, templateData));
|
|
1464
|
-
|
|
1465
|
-
const configRoot = `${projectDir}/modules/${projectDir}-config/src/main/java/${basePackagePath}/config`;
|
|
1466
|
-
actions.push(
|
|
1467
|
-
{ type: 'add', path: `${projectDir}/modules/${projectDir}-config/build.gradle`, templateFile: 'templates/multi-module/config/build.gradle.hbs', data: templateData },
|
|
1468
|
-
{ type: 'add', path: `${configRoot}/Config.java`, templateFile: 'templates/multi-module/config/Config.java.hbs', data: templateData },
|
|
1469
|
-
{ type: 'add', path: `${configRoot}/ApplicationConfig.java`, templateFile: 'templates/multi-module/config/ApplicationConfig.java.hbs', data: templateData },
|
|
1470
|
-
{ type: 'add', path: `${configRoot}/SecurityConfig.java`, templateFile: 'templates/multi-module/config/SecurityConfig.java.hbs', data: templateData }
|
|
1471
|
-
);
|
|
1472
|
-
|
|
1473
|
-
const dbConfigRoot = `${projectDir}/modules/${projectDir}-database-config/src/main/java/${basePackagePath}/databaseconfig`;
|
|
1474
|
-
actions.push(
|
|
1475
|
-
{ type: 'add', path: `${projectDir}/modules/${projectDir}-database-config/build.gradle`, templateFile: 'templates/multi-module/database-config/build.gradle.hbs', data: templateData },
|
|
1476
|
-
{ type: 'add', path: `${dbConfigRoot}/Config.java`, templateFile: 'templates/multi-module/database-config/Config.java.hbs', data: templateData },
|
|
1477
|
-
{ type: 'add', path: `${dbConfigRoot}/JpaConfig.java`, templateFile: 'templates/multi-module/database-config/JpaConfig.java.hbs', data: templateData },
|
|
1478
|
-
{ type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseViewDAO.java`, templateFile: 'templates/multi-module/database-config/BaseViewDAO.java.hbs', data: templateData },
|
|
1479
|
-
{ type: 'add', path: `${projectDir}/${layout.daoRoot}/BaseDAO.java`, templateFile: 'templates/multi-module/database-config/BaseDAO.java.hbs', data: templateData }
|
|
1480
|
-
);
|
|
1481
|
-
|
|
1482
|
-
for (const featureKey of data.features) {
|
|
1483
|
-
actions = actions.concat(
|
|
1484
|
-
featureActions(featureKey, {
|
|
1485
|
-
root,
|
|
1486
|
-
resourcesRoot,
|
|
1487
|
-
projectRoot: projectDir,
|
|
1488
|
-
ymlPath,
|
|
1489
|
-
gradlePath,
|
|
1490
|
-
templateData
|
|
1491
|
-
})
|
|
1492
|
-
);
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
actions.push({
|
|
1496
|
-
type: 'add',
|
|
1497
|
-
path: `${projectDir}/README.md`,
|
|
1498
|
-
templateFile: 'templates/base/README.md.hbs',
|
|
1499
|
-
data: templateData,
|
|
1500
|
-
skipIfExists: true
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
if (data.injectDocs) {
|
|
1504
|
-
actions.push(
|
|
1505
|
-
{ type: 'add', path: `${projectDir}/AGENTS.md`, templateFile: 'project-docs/AGENTS.md.hbs', data: templateData },
|
|
1506
|
-
{ type: 'add', path: `${projectDir}/PROJECT.md`, templateFile: 'project-docs/PROJECT.md.hbs', data: templateData },
|
|
1507
|
-
{ type: 'add', path: `${projectDir}/implement.md`, templateFile: 'project-docs/implement.hbs', data: templateData },
|
|
1508
|
-
{ type: 'add', path: `${projectDir}/test.md`, templateFile: 'project-docs/test.hbs', data: templateData }
|
|
1509
|
-
);
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
if ((data.features || []).includes('docker')) {
|
|
1513
|
-
actions.push({
|
|
1514
|
-
type: 'copyIfExists',
|
|
1515
|
-
from: `${projectDir}/.env.example`,
|
|
1516
|
-
path: `${projectDir}/.env`
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
return actions;
|
|
1521
|
-
}
|
|
338
|
+
actions: buildCreateActions
|
|
1522
339
|
});
|
|
1523
340
|
|
|
1524
|
-
// ---------- docs:給 AI coding agent 讀的目錄與規範 ----------
|
|
1525
341
|
plop.setGenerator('docs', {
|
|
1526
342
|
description: '產生 AGENTS.md / PROJECT.md / implement.md / test.md',
|
|
1527
343
|
prompts: [],
|
|
@@ -1538,102 +354,6 @@ module.exports = function (plop) {
|
|
|
1538
354
|
}
|
|
1539
355
|
});
|
|
1540
356
|
|
|
1541
|
-
function migrationDir(config) {
|
|
1542
|
-
const resourcesPath = config.resourcesPath || 'src/main/resources';
|
|
1543
|
-
return config.migrationPath || `${resourcesPath}/db/migration`;
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
function makeMigrationActions(data) {
|
|
1547
|
-
const config = loadProjectConfig();
|
|
1548
|
-
const migrationPath = migrationDir(config);
|
|
1549
|
-
const parsed = parseMigrationName(data.description);
|
|
1550
|
-
const version = data.migrationVersion || nextMigrationVersion(path.join(projectRoot(), migrationPath));
|
|
1551
|
-
const example = isExample(data);
|
|
1552
|
-
const migrationColumns = parsed.kind === 'create'
|
|
1553
|
-
? resolveCreateTableColumns(data, parsed.tableName, config)
|
|
1554
|
-
: [];
|
|
1555
|
-
return [
|
|
1556
|
-
{
|
|
1557
|
-
type: 'add',
|
|
1558
|
-
path: `${migrationPath}/V${version}__${parsed.description}.sql`,
|
|
1559
|
-
templateFile: migrationTemplate(parsed.kind),
|
|
1560
|
-
data: {
|
|
1561
|
-
description: parsed.description,
|
|
1562
|
-
tableName: parsed.tableName,
|
|
1563
|
-
columnName: parsed.columnName,
|
|
1564
|
-
withExample: example,
|
|
1565
|
-
withSoftDelete: includeSoftDelete(data),
|
|
1566
|
-
migrationColumns
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
];
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
function makeSeederActions(data) {
|
|
1573
|
-
data.entityName = entityNameForSeeder(data);
|
|
1574
|
-
const { root, templateData } = makeContext(data);
|
|
1575
|
-
const entityName = data.entityName;
|
|
1576
|
-
const seederClass = `${entityName}Seeder`;
|
|
1577
|
-
const tableName = tableNameFromEntity(entityName);
|
|
1578
|
-
const seedFields = resolveSeedFields(data, tableName, loadProjectConfig());
|
|
1579
|
-
return [
|
|
1580
|
-
...seederSkeletonActions(root, templateData, { skipIfExists: true }),
|
|
1581
|
-
{
|
|
1582
|
-
type: 'add',
|
|
1583
|
-
path: `${root}/seeder/${seederClass}.java`,
|
|
1584
|
-
templateFile: 'templates/seeder.hbs',
|
|
1585
|
-
data: {
|
|
1586
|
-
...templateData,
|
|
1587
|
-
extraImports: fieldImports(seedFields),
|
|
1588
|
-
seedFields,
|
|
1589
|
-
seederClass,
|
|
1590
|
-
entityName
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
];
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
function makeModelActions(data) {
|
|
1597
|
-
const actions = makeEntityActions(data, { skipIfExists: true });
|
|
1598
|
-
if (includeMapperWithEntity(data)) {
|
|
1599
|
-
actions.push(...makeMapperActions(data));
|
|
1600
|
-
}
|
|
1601
|
-
appendMakeSqlActions(actions, data);
|
|
1602
|
-
if (includeModelController(data)) {
|
|
1603
|
-
actions.push(...makeControllerActions(data));
|
|
1604
|
-
}
|
|
1605
|
-
return actions;
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
function appendMakeSqlActions(actions, data) {
|
|
1609
|
-
const config = loadProjectConfig();
|
|
1610
|
-
const migrationPath = migrationDir(config);
|
|
1611
|
-
const version = nextMigrationVersion(path.join(projectRoot(), migrationPath));
|
|
1612
|
-
if (includeModelMigration(data)) {
|
|
1613
|
-
const tableName = tableNameFromEntity(data.entityName);
|
|
1614
|
-
actions.push(
|
|
1615
|
-
...makeMigrationActions({
|
|
1616
|
-
...data,
|
|
1617
|
-
description: `create_${tableName}_table`,
|
|
1618
|
-
migrationVersion: version
|
|
1619
|
-
})
|
|
1620
|
-
);
|
|
1621
|
-
}
|
|
1622
|
-
if (includeModelSeed(data)) {
|
|
1623
|
-
actions.push(...makeSeederActions(data));
|
|
1624
|
-
}
|
|
1625
|
-
return actions;
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
const migrationPrompts = [
|
|
1629
|
-
{
|
|
1630
|
-
type: 'input',
|
|
1631
|
-
name: 'description',
|
|
1632
|
-
message: '遷移名稱(例如 create_users_table、add_votes_to_users_table):',
|
|
1633
|
-
validate: validateMigrationName
|
|
1634
|
-
}
|
|
1635
|
-
];
|
|
1636
|
-
|
|
1637
357
|
plop.setGenerator('make:migration', {
|
|
1638
358
|
description: '新增 Flyway 遷移(Laravel 風格名稱,例如 create_users_table)',
|
|
1639
359
|
prompts: migrationPrompts,
|