openxiangda 1.0.118 → 1.0.120

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/lib/cli.js CHANGED
@@ -1458,6 +1458,7 @@ async function workspace(args) {
1458
1458
  roles: {},
1459
1459
  connectors: {},
1460
1460
  dataViews: {},
1461
+ storageConfigs: {},
1461
1462
  pagePermissionGroups: {},
1462
1463
  formPermissionGroups: {},
1463
1464
  formSettings: {},
@@ -4909,6 +4910,7 @@ function ensureResourceBuckets(bound) {
4909
4910
  bound.resources.roles = bound.resources.roles || {};
4910
4911
  bound.resources.connectors = bound.resources.connectors || {};
4911
4912
  bound.resources.dataViews = bound.resources.dataViews || {};
4913
+ bound.resources.storageConfigs = bound.resources.storageConfigs || {};
4912
4914
  bound.resources.authConfigs = bound.resources.authConfigs || {};
4913
4915
  bound.resources.routes = bound.resources.routes || {};
4914
4916
  bound.resources.publicAccessPolicies = bound.resources.publicAccessPolicies || {};
@@ -5122,6 +5124,14 @@ function saveDataViewResource(target, dataViewCode, dataViewId, extra = {}) {
5122
5124
  }, keys);
5123
5125
  }
5124
5126
 
5127
+ function saveStorageConfigResource(target, storageCode, storageConfigId, extra = {}) {
5128
+ const keys = ['storageConfigId', 'provider', 'status'];
5129
+ saveStateResource(target, 'storageConfigs', storageCode, {
5130
+ ...pickStateFields(extra, keys),
5131
+ storageConfigId,
5132
+ }, keys);
5133
+ }
5134
+
5125
5135
  function saveRouteResource(target, routeCode, routeId, extra = {}) {
5126
5136
  const keys = ['routeId', 'pathPattern', 'publicAccess', 'publicPolicyCode'];
5127
5137
  saveStateResource(target, 'routes', routeCode, {
@@ -5253,6 +5263,7 @@ const RESOURCE_SPECS = [
5253
5263
  { key: 'automations', dir: 'automations', topFiles: ['automations.json'], pluralKeys: ['automations'] },
5254
5264
  { key: 'functions', dir: 'functions', topFiles: ['functions.json'], pluralKeys: ['functions'] },
5255
5265
  { key: 'dataViews', dir: 'data-views', topFiles: ['data-views.json'], pluralKeys: ['dataViews', 'data-views'] },
5266
+ { key: 'storageConfigs', dir: 'storage', topFiles: ['storage.json'], pluralKeys: ['storageConfigs', 'storage'] },
5256
5267
  { key: 'authConfigs', dir: 'auth', topFiles: ['auth.json'], pluralKeys: ['authConfigs', 'auth'] },
5257
5268
  { key: 'routes', dir: 'routes', topFiles: ['routes.json'], pluralKeys: ['routes'] },
5258
5269
  {
@@ -5305,6 +5316,15 @@ const RESOURCE_TYPE_ALIASES = new Map([
5305
5316
  ['dataview', 'dataViews'],
5306
5317
  ['dataviews', 'dataViews'],
5307
5318
  ['data-view-resource', 'dataViews'],
5319
+ ['storage', 'storageConfigs'],
5320
+ ['storages', 'storageConfigs'],
5321
+ ['storage-config', 'storageConfigs'],
5322
+ ['storage-configs', 'storageConfigs'],
5323
+ ['storageconfig', 'storageConfigs'],
5324
+ ['storageconfigs', 'storageConfigs'],
5325
+ ['oss', 'storageConfigs'],
5326
+ ['oss-config', 'storageConfigs'],
5327
+ ['oss-configs', 'storageConfigs'],
5308
5328
  ['auth', 'authConfigs'],
5309
5329
  ['auth-config', 'authConfigs'],
5310
5330
  ['auth-configs', 'authConfigs'],
@@ -5369,7 +5389,7 @@ function getResourceTypeFilters(positional = [], flags = {}) {
5369
5389
  if (!key) {
5370
5390
  fail(
5371
5391
  `未知资源类型: ${rawType}。常用类型包括 workflow, notification, route, menu, role, ` +
5372
- `page-permission-group, form-permission-group, public-access。`
5392
+ `storage, page-permission-group, form-permission-group, public-access。`
5373
5393
  );
5374
5394
  }
5375
5395
  filters.add(key);
@@ -5636,6 +5656,7 @@ function generateResourceTypes(manifest, outputFile) {
5636
5656
  );
5637
5657
  const dataViewCodes = unique((manifest.dataViews || []).map(item => item.code).filter(Boolean));
5638
5658
  const functionCodes = unique((manifest.functions || []).map(item => item.code).filter(Boolean));
5659
+ const storageConfigCodes = unique((manifest.storageConfigs || []).map(item => item.code).filter(Boolean));
5639
5660
  const authConfigCodes = unique((manifest.authConfigs || []).map(item => item.code).filter(Boolean));
5640
5661
  const publicAccessPolicyCodes = unique((manifest.publicAccessPolicies || []).map(item => item.code).filter(Boolean));
5641
5662
  const content = [
@@ -5654,6 +5675,9 @@ function generateResourceTypes(manifest, outputFile) {
5654
5675
  `export const dataViewCodes = ${JSON.stringify(dataViewCodes, null, 2)} as const`,
5655
5676
  'export type DataViewCode = typeof dataViewCodes[number]',
5656
5677
  '',
5678
+ `export const storageConfigCodes = ${JSON.stringify(storageConfigCodes, null, 2)} as const`,
5679
+ 'export type StorageConfigCode = typeof storageConfigCodes[number]',
5680
+ '',
5657
5681
  `export const functionCodes = ${JSON.stringify(functionCodes, null, 2)} as const`,
5658
5682
  'export type FunctionCode = typeof functionCodes[number]',
5659
5683
  '',
@@ -5678,6 +5702,7 @@ function generateResourceTypes(manifest, outputFile) {
5678
5702
  routeCodes: routeCodes.length,
5679
5703
  pagePermissionGroups: pagePermissionGroupCodes.length,
5680
5704
  dataViews: dataViewCodes.length,
5705
+ storageConfigs: storageConfigCodes.length,
5681
5706
  functions: functionCodes.length,
5682
5707
  authConfigs: authConfigCodes.length,
5683
5708
  publicAccessPolicies: publicAccessPolicyCodes.length,
@@ -5759,6 +5784,44 @@ function validateResourceItem(kind, item, errors, warnings) {
5759
5784
  }
5760
5785
  validateDataViewPerformance(label, definition, errors, warnings, storageMode);
5761
5786
  }
5787
+ if (kind === 'storageConfigs') {
5788
+ const config = item.configJson || item.config || item;
5789
+ const provider = String(item.provider || config.provider || 'oss').toLowerCase();
5790
+ if (provider !== 'oss') errors.push(`${label}: 当前仅支持 provider=oss`);
5791
+ if (!item.name && !config.name) warnings.push(`${label}: 未声明 name,将使用 code`);
5792
+ if (!config.region) errors.push(`${label}: 缺少 OSS region`);
5793
+ if (!config.bucket) errors.push(`${label}: 缺少 OSS bucket`);
5794
+ const status = item.status === undefined ? 'active' : String(item.status);
5795
+ if (!['active', 'disabled'].includes(status)) {
5796
+ errors.push(`${label}: status 只能是 active 或 disabled`);
5797
+ }
5798
+ const credentials = item.credentials || item.secret || item.secretJson || {};
5799
+ for (const key of ['accessKeyId', 'accessKeySecret']) {
5800
+ if (credentials[key] !== undefined && !isEnvReference(credentials[key])) {
5801
+ errors.push(`${label}.credentials.${key}: OSS 凭据必须使用环境变量引用`);
5802
+ }
5803
+ }
5804
+ if (!credentials.accessKeyId || !credentials.accessKeySecret) {
5805
+ warnings.push(`${label}: 未声明凭据;publish 只适用于更新并保留平台已有密文`);
5806
+ }
5807
+ const cors = config.cors || item.cors;
5808
+ if (cors?.managed === true) {
5809
+ const allowedOrigins = normalizeManifestStringArray(
5810
+ cors.allowedOrigins || cors.allowedOrigin || cors.origins || cors.origin
5811
+ );
5812
+ if (allowedOrigins.length === 0) {
5813
+ warnings.push(`${label}.cors.allowedOrigins: 未声明时后端将尝试使用当前平台域名`);
5814
+ }
5815
+ const allowedMethods = normalizeManifestStringArray(
5816
+ cors.allowedMethods || cors.allowedMethod || ['PUT', 'GET', 'HEAD']
5817
+ );
5818
+ if (allowedMethods.length === 0) errors.push(`${label}.cors.allowedMethods: 不能为空`);
5819
+ const maxAgeSeconds = Number(cors.maxAgeSeconds || 600);
5820
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds < 0 || maxAgeSeconds > 86400) {
5821
+ errors.push(`${label}.cors.maxAgeSeconds: 必须在 0 到 86400 秒之间`);
5822
+ }
5823
+ }
5824
+ }
5762
5825
  if (kind === 'authConfigs') {
5763
5826
  const config = item.configJson || item.config || item;
5764
5827
  if (!Array.isArray(config.methods)) {
@@ -6214,6 +6277,7 @@ async function buildResourcePlan(config, target, manifest) {
6214
6277
  addPlanActions(actions, 'publicAccessPolicy', manifest.publicAccessPolicies, existing.publicAccessPolicies, (item, current) => publicAccessPolicyEquals(target.bound, item, current));
6215
6278
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
6216
6279
  addPlanActions(actions, 'dataView', manifest.dataViews, existing.dataViews, (item, current) => dataViewEquals(target.bound, item, current));
6280
+ addPlanActions(actions, 'storageConfig', manifest.storageConfigs, existing.storageConfigs, (item, current) => storageConfigEquals(item, current));
6217
6281
  addPlanActions(actions, 'pagePermissionGroup', manifest.pagePermissionGroups, existing.pagePermissionGroups, (item, current) => pagePermissionGroupEquals(target.bound, item, current));
6218
6282
  addPlanActions(actions, 'formPermissionGroup', manifest.formPermissionGroups, existing.formPermissionGroups, (item, current) => formPermissionGroupEquals(target.bound, item, current));
6219
6283
  for (const item of manifest.formSettings || []) {
@@ -6287,6 +6351,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
6287
6351
  await publishRouteResources(config, target, manifest.routes || [], result, publishOptions);
6288
6352
  await publishAutomationResources(config, target, manifest.automations || [], result, publishOptions);
6289
6353
  await publishDataViewResources(config, target, manifest.dataViews || [], result, publishOptions);
6354
+ await publishStorageConfigResources(config, target, manifest.storageConfigs || [], result, publishOptions);
6290
6355
  await publishPublicAccessPolicyResources(config, target, manifest.publicAccessPolicies || [], result, publishOptions);
6291
6356
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result, publishOptions);
6292
6357
  await publishFormPermissionGroupResources(config, target, manifest.formPermissionGroups || [], result, publishOptions);
@@ -6314,6 +6379,7 @@ async function fetchExistingResourceMaps(config, target, manifest) {
6314
6379
  publicAccessPolicies: new Map(),
6315
6380
  automations: new Map(),
6316
6381
  dataViews: new Map(),
6382
+ storageConfigs: new Map(),
6317
6383
  pagePermissionGroups: new Map(),
6318
6384
  formPermissionGroups: new Map(),
6319
6385
  };
@@ -6495,6 +6561,31 @@ async function fetchExistingResourceMaps(config, target, manifest) {
6495
6561
  });
6496
6562
  }
6497
6563
  }
6564
+ if ((manifest.storageConfigs || []).length > 0) {
6565
+ const data = await requestWithAuth(
6566
+ config,
6567
+ target.profileName,
6568
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs`, {
6569
+ page: 1,
6570
+ pageSize: 1000,
6571
+ })
6572
+ );
6573
+ indexByCode(maps.storageConfigs, normalizeItems(data), item => item.code || item.resourceCode);
6574
+ for (const item of manifest.storageConfigs || []) {
6575
+ const code = item.code || item.resourceCode;
6576
+ const existing = maps.storageConfigs.get(code);
6577
+ if (!existing) continue;
6578
+ const detail = await requestOptionalWithAuth(
6579
+ config,
6580
+ target.profileName,
6581
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs/${encodeURIComponent(code)}`
6582
+ );
6583
+ maps.storageConfigs.set(code, {
6584
+ ...existing,
6585
+ ...(detail || {}),
6586
+ });
6587
+ }
6588
+ }
6498
6589
  if ((manifest.pagePermissionGroups || []).length > 0) {
6499
6590
  const data = await requestWithAuth(
6500
6591
  config,
@@ -7246,6 +7337,44 @@ async function publishDataViewResources(config, target, dataViews, result, optio
7246
7337
  }
7247
7338
  }
7248
7339
 
7340
+ async function publishStorageConfigResources(config, target, storageConfigs, result, options = {}) {
7341
+ for (const storageItem of storageConfigs) {
7342
+ const code = storageItem.code || storageItem.resourceCode;
7343
+ if (shouldSkipNoopResource(options, 'storageConfig', code)) {
7344
+ recordNoopResource(result, 'storageConfig', code, options);
7345
+ continue;
7346
+ }
7347
+ const existing = await findExistingStorageConfig(config, target, code);
7348
+ const body = normalizeStorageConfigManifest(storageItem);
7349
+ const data = existing
7350
+ ? await requestWithAuth(
7351
+ config,
7352
+ target.profileName,
7353
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs/${encodeURIComponent(code)}`,
7354
+ { method: 'PUT', body }
7355
+ )
7356
+ : await requestWithAuth(
7357
+ config,
7358
+ target.profileName,
7359
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs`,
7360
+ { method: 'POST', body }
7361
+ );
7362
+ if (data?.id) {
7363
+ saveStorageConfigResource(target, data.code || code, data.id, {
7364
+ provider: data.provider || body.provider,
7365
+ status: data.status || body.status,
7366
+ });
7367
+ }
7368
+ result.published.push({
7369
+ kind: 'storageConfig',
7370
+ code,
7371
+ action: existing ? 'update' : 'create',
7372
+ id: data?.id,
7373
+ corsStatus: data?.corsStatus,
7374
+ });
7375
+ }
7376
+ }
7377
+
7249
7378
  async function publishDataViewPermissionGroups(config, target, dataViewCode, permissionGroups) {
7250
7379
  await requestWithAuth(
7251
7380
  config,
@@ -7398,6 +7527,18 @@ async function findExistingDataView(config, target, code) {
7398
7527
  return null;
7399
7528
  }
7400
7529
 
7530
+ async function findExistingStorageConfig(config, target, code) {
7531
+ const stateId = target.bound.resources?.storageConfigs?.[code]?.storageConfigId;
7532
+ const byCode = await requestOptionalWithAuth(
7533
+ config,
7534
+ target.profileName,
7535
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs/${encodeURIComponent(code)}`
7536
+ );
7537
+ if (byCode?.id) return byCode;
7538
+ if (stateId) return { id: stateId, code };
7539
+ return null;
7540
+ }
7541
+
7401
7542
  async function findExistingAutomation(config, target, code) {
7402
7543
  const stateId = target.bound.resources?.automations?.[code]?.automationId;
7403
7544
  if (stateId) {
@@ -7545,6 +7686,7 @@ async function pruneResourceManifest(config, target, manifest, result) {
7545
7686
  await pruneAuthConfigs(config, target, desiredCodes(manifest.authConfigs), result);
7546
7687
  await pruneRoutes(config, target, desiredCodes(manifest.routes), result);
7547
7688
  await pruneDataViews(config, target, desiredCodes(manifest.dataViews), result);
7689
+ await pruneStorageConfigs(config, target, desiredCodes(manifest.storageConfigs), result);
7548
7690
  await prunePublicAccessPolicies(
7549
7691
  config,
7550
7692
  target,
@@ -7722,6 +7864,29 @@ async function pruneDataViews(config, target, desired, result) {
7722
7864
  }
7723
7865
  }
7724
7866
 
7867
+ async function pruneStorageConfigs(config, target, desired, result) {
7868
+ const data = await requestWithAuth(
7869
+ config,
7870
+ target.profileName,
7871
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs`, {
7872
+ page: 1,
7873
+ pageSize: 1000,
7874
+ })
7875
+ );
7876
+ for (const item of normalizeItems(data)) {
7877
+ const code = item.code || item.resourceCode;
7878
+ if (!code || desired.has(code)) continue;
7879
+ await pruneOne(config, target, result, 'storageConfig', code, item.id, async () => {
7880
+ await requestWithAuth(
7881
+ config,
7882
+ target.profileName,
7883
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs/${encodeURIComponent(code)}`,
7884
+ { method: 'DELETE' }
7885
+ );
7886
+ });
7887
+ }
7888
+ }
7889
+
7725
7890
  async function pruneFunctions(config, target, desired, result) {
7726
7891
  const data = await requestWithAuth(
7727
7892
  config,
@@ -7893,6 +8058,7 @@ function removeStateResource(target, kind, code) {
7893
8058
  route: 'routes',
7894
8059
  publicAccessPolicy: 'publicAccessPolicies',
7895
8060
  dataView: 'dataViews',
8061
+ storageConfig: 'storageConfigs',
7896
8062
  pagePermissionGroup: 'pagePermissionGroups',
7897
8063
  formPermissionGroup: 'formPermissionGroups',
7898
8064
  };
@@ -8165,6 +8331,26 @@ async function pullResources(config, target) {
8165
8331
  written.push(path.relative(process.cwd(), filePath));
8166
8332
  }
8167
8333
 
8334
+ const storageConfigs = await requestWithAuth(
8335
+ config,
8336
+ target.profileName,
8337
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs`, {
8338
+ page: 1,
8339
+ pageSize: 1000,
8340
+ })
8341
+ );
8342
+ for (const storageConfig of normalizeItems(storageConfigs)) {
8343
+ const code = storageConfig.code || storageConfig.resourceCode || storageConfig.id;
8344
+ const detail = await requestWithAuth(
8345
+ config,
8346
+ target.profileName,
8347
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/storage-configs/${encodeURIComponent(code)}`
8348
+ );
8349
+ const filePath = path.join(baseDir, 'storage', `${code}.json`);
8350
+ writeResourceJsonFile(filePath, toPulledStorageConfig(detail));
8351
+ written.push(path.relative(process.cwd(), filePath));
8352
+ }
8353
+
8168
8354
  const pageGroups = await requestWithAuth(
8169
8355
  config,
8170
8356
  target.profileName,
@@ -8407,6 +8593,18 @@ function toPulledAuthConfig(authConfig) {
8407
8593
  });
8408
8594
  }
8409
8595
 
8596
+ function toPulledStorageConfig(storageConfig) {
8597
+ return stripUndefinedValues({
8598
+ code: storageConfig.code || storageConfig.resourceCode,
8599
+ name: storageConfig.name,
8600
+ description: storageConfig.description || '',
8601
+ provider: storageConfig.provider || 'oss',
8602
+ status: storageConfig.status || 'active',
8603
+ configJson: storageConfig.configJson || storageConfig.config || {},
8604
+ credentialStatus: storageConfig.credentials || storageConfig.credentialStatus || undefined,
8605
+ });
8606
+ }
8607
+
8410
8608
  function rewriteDataViewDefinitionForManifest(definition, lookups) {
8411
8609
  rewriteDataViewSourceForManifest(definition.base, lookups);
8412
8610
  for (const join of definition.joins || []) {
@@ -8657,6 +8855,135 @@ function normalizeAuthConfigManifest(authConfig) {
8657
8855
  });
8658
8856
  }
8659
8857
 
8858
+ function normalizeStorageConfigManifest(storageConfig) {
8859
+ const code = storageConfig.code || storageConfig.resourceCode;
8860
+ const configJson = clonePlainJson(
8861
+ storageConfig.configJson ||
8862
+ storageConfig.config ||
8863
+ withoutStorageConfigManifestMeta(storageConfig)
8864
+ );
8865
+ const cors = normalizeStorageCorsForPublish(configJson.cors || storageConfig.cors);
8866
+ const credentials = normalizeStorageCredentialsForPublish(storageConfig);
8867
+ return stripUndefinedValues({
8868
+ code,
8869
+ name: storageConfig.name || configJson.name || code,
8870
+ description:
8871
+ storageConfig.description !== undefined
8872
+ ? storageConfig.description
8873
+ : configJson.description || '',
8874
+ provider: storageConfig.provider || configJson.provider || 'oss',
8875
+ status: storageConfig.status || configJson.status || 'active',
8876
+ configJson: stripUndefinedValues({
8877
+ region: configJson.region,
8878
+ bucket: configJson.bucket,
8879
+ endpoint: configJson.endpoint,
8880
+ publicBaseUrl: configJson.publicBaseUrl,
8881
+ pathPrefix: configJson.pathPrefix,
8882
+ maxFileSizeMb: configJson.maxFileSizeMb,
8883
+ allowedExtensions: configJson.allowedExtensions,
8884
+ uploadUrlExpiresSeconds: configJson.uploadUrlExpiresSeconds,
8885
+ cors,
8886
+ }),
8887
+ ...(credentials ? { credentials } : {}),
8888
+ });
8889
+ }
8890
+
8891
+ function normalizeStorageCorsForPublish(cors) {
8892
+ if (cors === undefined || cors === null || cors === '') return undefined;
8893
+ const managed =
8894
+ cors === true ||
8895
+ cors.managed === true ||
8896
+ String(cors.managed || '').toLowerCase() === 'true';
8897
+ if (!managed) return { managed: false };
8898
+ return stripUndefinedValues({
8899
+ managed: true,
8900
+ allowedOrigins: normalizeManifestStringArray(
8901
+ cors.allowedOrigins || cors.allowedOrigin || cors.origins || cors.origin
8902
+ ),
8903
+ allowedMethods: normalizeManifestStringArray(
8904
+ cors.allowedMethods || cors.allowedMethod || ['PUT', 'GET', 'HEAD'],
8905
+ { uppercase: true }
8906
+ ),
8907
+ allowedHeaders: normalizeManifestStringArray(
8908
+ cors.allowedHeaders || cors.allowedHeader || ['content-type', 'x-oss-*']
8909
+ ),
8910
+ exposeHeaders: normalizeManifestStringArray(
8911
+ cors.exposeHeaders || cors.exposeHeader || ['ETag', 'x-oss-request-id']
8912
+ ),
8913
+ maxAgeSeconds: Number(cors.maxAgeSeconds || 600),
8914
+ });
8915
+ }
8916
+
8917
+ function withoutStorageConfigManifestMeta(storageConfig) {
8918
+ const next = withoutResourceMeta(storageConfig);
8919
+ delete next.code;
8920
+ delete next.resourceCode;
8921
+ delete next.name;
8922
+ delete next.description;
8923
+ delete next.provider;
8924
+ delete next.status;
8925
+ delete next.cors;
8926
+ delete next.config;
8927
+ delete next.configJson;
8928
+ delete next.credentials;
8929
+ delete next.secret;
8930
+ delete next.secretJson;
8931
+ delete next.credentialStatus;
8932
+ return next;
8933
+ }
8934
+
8935
+ function normalizeStorageCredentialsForPublish(storageConfig) {
8936
+ const source =
8937
+ storageConfig.credentials || storageConfig.secret || storageConfig.secretJson || {};
8938
+ const hasAccessKeyId = source.accessKeyId !== undefined;
8939
+ const hasAccessKeySecret = source.accessKeySecret !== undefined;
8940
+ if (!hasAccessKeyId && !hasAccessKeySecret) return null;
8941
+ return {
8942
+ accessKeyId: resolveEnvReference(source.accessKeyId, 'OSS accessKeyId'),
8943
+ accessKeySecret: resolveEnvReference(
8944
+ source.accessKeySecret,
8945
+ 'OSS accessKeySecret'
8946
+ ),
8947
+ };
8948
+ }
8949
+
8950
+ let cachedManifestEnv = null;
8951
+
8952
+ function getManifestEnv() {
8953
+ if (!cachedManifestEnv) {
8954
+ cachedManifestEnv = {
8955
+ ...loadGlobalEnv(),
8956
+ ...process.env,
8957
+ };
8958
+ }
8959
+ return cachedManifestEnv;
8960
+ }
8961
+
8962
+ function isEnvReference(value) {
8963
+ return Boolean(parseEnvReferenceName(value));
8964
+ }
8965
+
8966
+ function resolveEnvReference(value, label) {
8967
+ const envName = parseEnvReferenceName(value);
8968
+ if (!envName) fail(`${label} 必须使用环境变量引用`);
8969
+ const env = getManifestEnv();
8970
+ const resolved = env[envName];
8971
+ if (resolved === undefined || resolved === '') {
8972
+ fail(`${label} 引用的环境变量未设置: ${envName}`);
8973
+ }
8974
+ return resolved;
8975
+ }
8976
+
8977
+ function parseEnvReferenceName(value) {
8978
+ const text = String(value || '').trim();
8979
+ const match =
8980
+ text.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/) ||
8981
+ text.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) ||
8982
+ text.match(/^env:([A-Za-z_][A-Za-z0-9_]*)$/) ||
8983
+ text.match(/^\{\{env\.([A-Za-z_][A-Za-z0-9_]*)\}\}$/);
8984
+ return match?.[1];
8985
+ }
8986
+
8660
8987
  function withoutAuthConfigManifestMeta(authConfig) {
8661
8988
  const next = withoutResourceMeta(authConfig);
8662
8989
  delete next.code;
@@ -8934,6 +9261,16 @@ function normalizePermissionCodeArray(value) {
8934
9261
  );
8935
9262
  }
8936
9263
 
9264
+ function normalizeManifestStringArray(value, options = {}) {
9265
+ const source = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
9266
+ const items = source
9267
+ .flatMap(item => String(item || '').split(','))
9268
+ .map(item => item.trim())
9269
+ .filter(Boolean)
9270
+ .map(item => options.uppercase ? item.toUpperCase() : item);
9271
+ return unique(items);
9272
+ }
9273
+
8937
9274
  function saveResourceEntry(target, bucket, code, extra = {}) {
8938
9275
  saveStateResource(target, bucket, code, pickStateFields(extra, ['formUuid']), [
8939
9276
  'formUuid',
@@ -9188,6 +9525,24 @@ function authConfigEquals(desired, existing) {
9188
9525
  );
9189
9526
  }
9190
9527
 
9528
+ function storageConfigEquals(desired, existing) {
9529
+ if (!existing) return false;
9530
+ const expected = normalizeStorageConfigManifest({
9531
+ ...desired,
9532
+ credentials: undefined,
9533
+ secret: undefined,
9534
+ secretJson: undefined,
9535
+ });
9536
+ return (
9537
+ String(existing.code || existing.resourceCode || '') === String(expected.code || '') &&
9538
+ String(existing.name || '') === String(expected.name || '') &&
9539
+ String(existing.description || '') === String(expected.description || '') &&
9540
+ String(existing.provider || 'oss') === String(expected.provider || 'oss') &&
9541
+ String(existing.status || 'active') === String(expected.status || 'active') &&
9542
+ jsonEqualsForPlan(existing.configJson || existing.config || {}, expected.configJson || {}, desired.__dir)
9543
+ );
9544
+ }
9545
+
9191
9546
  function routeEquals(desired, existing) {
9192
9547
  if (!existing) return false;
9193
9548
  const expected = normalizeRouteManifest(desired);
@@ -57,6 +57,7 @@ function initWorkspace(options = {}) {
57
57
  roles: {},
58
58
  connectors: {},
59
59
  dataViews: {},
60
+ storageConfigs: {},
60
61
  notifications: {
61
62
  templates: {},
62
63
  typeConfigs: {},
@@ -59,7 +59,7 @@ AI 在实现成熟交互前必须先判断是否已有可靠组件或库:
59
59
  | --- | --- | --- | --- |
60
60
  | 选择人员 | `UserSelectField` | 直接接入组织架构 | 人员列表、搜索、按部门筛选、层级树 |
61
61
  | 选择部门 | `DepartmentSelectField` | 直接接入组织架构 | 部门树、搜索、多选 |
62
- | 上传附件 | `AttachmentField` | 接入平台存储 | 上传、预览、下载、鉴权 URL |
62
+ | 上传附件 | `AttachmentField` | 接入平台存储或声明式 OSS 存储 | 上传、预览、下载、鉴权 URL 或真实 OSS URL |
63
63
  | 上传图片 | `ImageField` | 接入平台存储 | 上传、压缩、缩略图、预览 |
64
64
  | 富文本编辑 | `EditorField` | 含图片上传集成 | 完整工具栏、平台图床、粘贴清洗 |
65
65
  | 数字签名 | `DigitalSignatureField` | 平台级签名 | 签名采集、验证、归档 |
@@ -75,12 +75,15 @@ import {
75
75
  AttachmentField,
76
76
  } from 'openxiangda';
77
77
 
78
- <UserSelectField name="owner" label="负责人" multiple={false} />
79
- <DepartmentSelectField name="dept" label="所属部门" />
80
- <AttachmentField name="files" label="附件" maxCount={5} />
81
- ```
82
-
83
- > ✅ 一句话原则:**这些场景看到了,直接抄上表,不要自己写。**
78
+ <UserSelectField name="owner" label="负责人" multiple={false} />
79
+ <DepartmentSelectField name="dept" label="所属部门" />
80
+ <AttachmentField name="files" label="附件" maxCount={5} />
81
+ <AttachmentField name="ossFiles" label="OSS 附件" uploadProvider="oss" storageCode="evaluate_oss" />
82
+ ```
83
+
84
+ OSS 浏览器直传所需的 CORS 规则在 `src/resources/storage/<code>.json` 的 `configJson.cors.managed` 中声明,不要把 bucket 配置写进组件 props。
85
+
86
+ > ✅ 一句话原则:**这些场景看到了,直接抄上表,不要自己写。**
84
87
 
85
88
  ---
86
89
 
@@ -75,6 +75,7 @@ Rules:
75
75
  - For `CascadeDateField`, store a date range and document whether the value is inclusive.
76
76
  - For `UserSelectField` and `DepartmentSelectField`, do not invent user or department IDs. Use runtime/platform selection.
77
77
  - For `AttachmentField` and `ImageField`, do not hardcode OSS URLs in defaults unless the file has already been uploaded by platform tooling.
78
+ - For custom OSS attachment upload, first declare a `src/resources/storage/<code>.json` resource, optionally enable `configJson.cors.managed` for browser direct-upload CORS, then set `uploadProvider: "oss"` and `storageCode: "<code>"` on `AttachmentField`. Never put OSS AK/SK values in schema, page code, or committed resource files.
78
79
  - For `SubFormField`, use `columns` for child fields and keep child `fieldId` values stable within the subform.
79
80
  - `TextAreaField` is accepted by workspace tools and normalized to runtime `TextareaField`; prefer `TextAreaField` in OpenXiangda docs for readability.
80
81
 
@@ -643,3 +643,71 @@ Requires Bearer token. Returns runtime bootstrap information for an existing cod
643
643
  ### GET `/apps/:appType/snapshot`
644
644
 
645
645
  Requires Bearer token. Returns app metadata, forms, menus, code page definitions, code page releases, and permission hints.
646
+
647
+ ## Storage Configs
648
+
649
+ Storage config APIs manage application-level custom upload targets. The first provider is Aliyun OSS.
650
+
651
+ ### GET `/apps/:appType/storage-configs`
652
+
653
+ Requires Bearer token and storage-config manage permission. Lists storage configs.
654
+
655
+ ### POST `/apps/:appType/storage-configs`
656
+
657
+ Requires Bearer token and storage-config manage permission. Creates or upserts a storage config.
658
+
659
+ Body:
660
+
661
+ ```json
662
+ {
663
+ "code": "evaluate_oss",
664
+ "name": "Evaluate OSS",
665
+ "provider": "oss",
666
+ "status": "active",
667
+ "configJson": {
668
+ "region": "oss-cn-hangzhou",
669
+ "bucket": "evaluate-oss",
670
+ "publicBaseUrl": "https://evaluate-oss.oss-cn-hangzhou.aliyuncs.com",
671
+ "pathPrefix": "openxiangda/{{appType}}/{{yyyy}}/{{MM}}/{{dd}}",
672
+ "allowedExtensions": ["txt", "pdf", "png"],
673
+ "cors": {
674
+ "managed": true,
675
+ "allowedOrigins": ["https://yida.wisejob.cn"],
676
+ "allowedMethods": ["PUT", "GET", "HEAD"],
677
+ "allowedHeaders": ["content-type", "x-oss-*"],
678
+ "exposeHeaders": ["ETag", "x-oss-request-id"],
679
+ "maxAgeSeconds": 600
680
+ }
681
+ },
682
+ "credentials": {
683
+ "accessKeyId": "resolved at publish time",
684
+ "accessKeySecret": "resolved at publish time"
685
+ }
686
+ }
687
+ ```
688
+
689
+ When `configJson.cors.managed` is true, the backend attempts to merge the required OSS bucket CORS rule after saving the storage config. The response may include `corsStatus`; `applied: false` means the storage config was saved but the OSS credentials could not manage bucket CORS.
690
+
691
+ ### GET `/apps/:appType/storage-configs/:code`
692
+
693
+ Requires Bearer token and storage-config manage permission. Returns the storage config with credential status only, never plaintext AK/SK.
694
+
695
+ ### PUT `/apps/:appType/storage-configs/:code`
696
+
697
+ Requires Bearer token and storage-config manage permission. Updates the storage config. Omit `credentials` to keep the existing encrypted credentials.
698
+
699
+ ### DELETE `/apps/:appType/storage-configs/:code`
700
+
701
+ Requires Bearer token and storage-config manage permission. Deletes the storage config metadata; it does not delete existing OSS objects.
702
+
703
+ ### POST `/apps/:appType/storage-configs/:code/cors/apply`
704
+
705
+ Requires Bearer token and storage-config manage permission. Re-applies the declared managed CORS rule for an existing config.
706
+
707
+ ### POST `/apps/:appType/storage-configs/:code/uploads/initiate`
708
+
709
+ Requires Bearer token. Creates a short-lived signed OSS upload URL for browser direct upload.
710
+
711
+ ### POST `/apps/:appType/storage-configs/:code/objects/delete`
712
+
713
+ Requires Bearer token. Deletes an OSS object under the configured `pathPrefix`.
@@ -43,7 +43,7 @@
43
43
  | 人员选择(单) | `UserSelectField` (单选) | `{"label": "张三", "value": "user_001"}` | `value.label` | `value.value` 作为 userId |
44
44
  | 人员选择(多) | `UserSelectField` (多选) | `[{"label":"张三","value":"user_001"},{"label":"李四","value":"user_002"}]` | `items.map(i => i.label).join(', ')` | `items.map(i => i.value)` 作为 userId 列表 |
45
45
  | 部门选择 | `DepartmentSelectField` | 单选 `{"label": "研发部", "value": "dept_001"}`;多选 `[{...}, ...]` | `value.label` 或 `items.map(i => i.label)` | `value.value` / `items.map(i => i.value)` |
46
- | 附件 | `AttachmentField` | `[{"name":"文件.pdf","url":"https://...","size":1024,"type":"application/pdf"}]` | 文件名+下载链接列表 | 通过 `url` 下载 |
46
+ | 附件 | `AttachmentField` | `[{"name":"文件.pdf","url":"https://...","size":1024,"type":"application/pdf","provider":"platform或oss","storageCode":"evaluate_oss"}]` | 文件名+下载链接列表 | 通过 `url` 下载;OSS 附件保存真实 OSS URL |
47
47
  | 图片 | `ImageField` | `[{"name":"图片.png","url":"https://...","thumbUrl":"https://..."}]` | 缩略图网格 | 通过 `url` 预览 |
48
48
  | 子表单 | `SubFormField` | `[{字段A: ..., 字段B: ...}, {...}]` (对象数组) | 渲染为子行表格 | 遍历每行,按字段 code 取值 |
49
49
  | 位置 | `LocationField` | `{"address":"杭州市xxx","lng":120.12,"lat":30.27}` | `value.address` | `value.lng`/`value.lat` 用于地图 |
@@ -244,7 +244,7 @@ const filter = {
244
244
  3. **提交表单时必须组装完整 `{label, value}` 结构**:仅传 value 会导致历史展示丢失中文名。
245
245
  4. **多选字段始终是数组**,即便只有一项也写成 `[{label, value}]`;空值用 `[]`,不要用 `null`。
246
246
  5. **`UserSelectField` / `DepartmentSelectField` 与普通 `SelectField` 的格式完全一致**,唯一区别是 `value` 语义为 userId / departmentId。
247
- 6. **附件 / 图片字段始终是数组**:单文件场景也是 `[{name, url, ...}]`,没有则 `[]`。
247
+ 6. **附件 / 图片字段始终是数组**:单文件场景也是 `[{name, url, ...}]`,没有则 `[]`。使用 OSS 自定义上传时,附件项会额外带 `provider: "oss"`、`storageCode`、`publicUrl` 等元信息,但仍以 `url` 作为主访问地址。
248
248
  7. **子表单 `SubFormField` 是对象数组**,每个对象代表一行,键为子字段 code。读取时遍历数组,按字段 code 访问;空子表单为 `[]`。
249
249
  8. **不要尝试"翻译"或"规范化" label/value**:数据库存的就是最终展示格式,前后端皆然。任何二次翻译都是 bug 来源。
250
250
  9. **流程表单的数据格式与普通表单一致**:`formData` 结构与普通表单一致,流程节点信息走另外的字段,不影响业务字段结构。