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