diff --git a/deploy/docker-compose/.env b/deploy/docker-compose/.env index cb903bf..c8b9d27 100644 --- a/deploy/docker-compose/.env +++ b/deploy/docker-compose/.env @@ -16,10 +16,4 @@ NGINX_HTTPS_PORT=443 # Project base domain (used for subdomain routing) # e.g. preview.flexmodel.dev → {projectId}.preview.flexmodel.dev -FLEXMODEL_PROJECT_BASE_DOMAIN=localhost -# Routing mode: "path" for local development (path-based), "subdomain" for production. -# In "path" mode, pages are served at /pages/{projectId}, -# functions are invoked at /functions/{projectId}/{name}. -# In "subdomain" mode, pages are served at {projectId}.{projectBaseDomain}, -# functions are invoked at {projectId}.{projectBaseDomain}/functions/{name}. -FLEXMODEL_PROJECT_ROUTING_MODE=path \ No newline at end of file +FLEXMODEL_PROJECT_BASE_DOMAIN=flexmodel.wetech.tech \ No newline at end of file diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml index b1d8442..30130a6 100644 --- a/deploy/docker-compose/docker-compose.yml +++ b/deploy/docker-compose/docker-compose.yml @@ -77,7 +77,6 @@ services: - QUARKUS_REST_CLIENT_FUNCTION_RUNTIME_URL=http://flexmodel-functions-runtime:9999 - QUARKUS_HTTP_LIMITS_MAX_BODY_SIZE=100M - FLEXMODEL_PAGES_ROOT=/data/pages - - FLEXMODEL_PROJECT_ROUTING_MODE=${FLEXMODEL_PROJECT_ROUTING_MODE:-path} - FLEXMODEL_PAGES_URL_TEMPLATE=${FLEXMODEL_PAGES_URL_TEMPLATE:-https://{{projectId}}.example.com} - FLEXMODEL_EDGE_URL_TEMPLATE=${FLEXMODEL_EDGE_URL_TEMPLATE:-https://{{projectId}}.example.com/functions/{{name}}} diff --git a/deploy/docker-compose/nginx/conf.d/default.conf.template b/deploy/docker-compose/nginx/conf.d/default.conf.template index df0bf28..eed8222 100644 --- a/deploy/docker-compose/nginx/conf.d/default.conf.template +++ b/deploy/docker-compose/nginx/conf.d/default.conf.template @@ -1,5 +1,16 @@ # ============================================================ -# Main domain → Java API + UI +# Pages alias subpath segment — avoids "//" when alias is empty. +# Evaluated lazily from $alias captured by the subdomain server_name. +# ============================================================ +map $alias $alias_path { + "" ""; + default "/$alias"; +} + +# ============================================================ +# Main domain → Admin API + UI +# /api/ → Java (admin API: /api/projects/... etc.) +# / → UI (flexmodel-ui) # ============================================================ server { listen 80; @@ -7,7 +18,7 @@ server { client_max_body_size 1024m; - # API reverse proxy + # Admin API → Java location /api/ { proxy_pass http://flexmodel-server:8080; proxy_set_header Host $host; @@ -20,49 +31,11 @@ server { add_header Cache-Control no-cache; } - # Pages path-based routing (dev / path mode) - # /pages/{projectId}/ → production alias - # /pages/{projectId}/{alias}/ → preview / short alias - location ~ ^/pages/(?[^/]+)(/(?[^/]+))?/(?.*)$ { + # Pages (path mode) — /pages/{projectId}/... → /data/pages/{projectId}/production/... + # 当 project-base-domain 为空时自动走 path 模式。 + location ~ ^/pages/(?[^/]+)(?.*)$ { root /data/pages; - - set $pages_path $projectId/production/$subpath; - if ($alias) { - set $pages_path $projectId/$alias/$subpath; - } - - try_files /$pages_path /$projectId/production/$subpath /$pages_path/index.html /$projectId/production/index.html =404; - - types { - application/javascript js mjs; - text/css css; - text/html html; - application/json json; - image/svg+xml svg; - image/png png; - image/jpeg jpg jpeg; - image/gif gif; - image/x-icon ico; - font/woff woff; - font/woff2 woff2; - font/ttf ttf; - application/manifest+json webmanifest; - } - } - - location ~ ^/pages/(?[^/]+)/?$ { - root /data/pages; - try_files /$projectId/production/index.html =404; - } - - # Edge function invocation → Deno Runtime (path mode: /functions/{projectId}/{name}) - location /functions/ { - proxy_pass http://flexmodel-functions-runtime:9999; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_http_version 1.1; + try_files /$pproj/production$prest /$pproj/production/index.html =404; } # UI reverse proxy @@ -82,48 +55,39 @@ server { } } -# Build subpath segment for pages — avoids "//" when alias is empty -map $alias $alias_path { - "" ""; - default "/$alias"; -} - # ============================================================ -# Wildcard subdomain → Pages + Edge Functions -# {projectId}.{domain} → pages (production) -# {alias}.{projectId}.{domain} → pages (alias) -# /functions/ → Deno Runtime +# Subdomain → Pages + Open API (data plane) +# {projectId}.{domain} → pages (production) +# {alias}.{projectId}.{domain} → pages (alias) +# /api/open/{projectId}/... → Java (open API) # ============================================================ server { listen 80; - server_name *.localhost - ~^(?[^.]+)\.(?[^.]+)\.${FLEXMODEL_PROJECT_BASE_DOMAIN_ESCAPED}$ + server_name ~^(?[^.]+)\.(?[^.]+)\.${FLEXMODEL_PROJECT_BASE_DOMAIN_ESCAPED}$ ~^(?[^.]+)\.${FLEXMODEL_PROJECT_BASE_DOMAIN_ESCAPED}$; root /data/pages; - index index.html; client_max_body_size 1024m; - # Edge function invocation → Deno Runtime - # Subdomain mode: client calls /functions/{name}; runtime expects /functions/{projectId}/{name}, - # so inject the projectId captured from the server_name regex back into the path. - location /functions/ { - rewrite ^/functions/([^/]+)$ /functions/$projectId/$1 break; - proxy_pass http://flexmodel-functions-runtime:9999; + # Open API → Java (SDK sends /api/open/{projectId}/..., no rewrite needed) + location /api/ { + proxy_pass http://flexmodel-server:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_http_version 1.1; + add_header Cache-Control no-cache; } - # Set pages_base at server level so all locations can use it + # Pages base path (projectId + optional alias segment) set $pages_base $projectId$alias_path; # Static assets — long cache (hash filenames) location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|ico|mjs)$ { - set $file_base $projectId$alias_path; - try_files /$file_base$uri /$projectId/production$uri =404; + try_files /$pages_base$uri /$projectId/production$uri =404; expires 30d; add_header Cache-Control "public, immutable"; } @@ -136,22 +100,6 @@ server { # Pages — SPA fallback (for non-root paths) location / { try_files /$pages_base$uri /$pages_base/index.html /$projectId/production$uri /$projectId/production/index.html =404; - types { - application/javascript js mjs; - text/css css; - text/html html; - application/json json; - image/svg+xml svg; - image/png png; - image/jpeg jpg jpeg; - image/gif gif; - image/x-icon ico; - font/woff woff; - font/woff2 woff2; - font/ttf ttf; - application/manifest+json webmanifest; - } } - error_page 404 /404.html; } diff --git a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/service/SchemaService.java b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/service/SchemaService.java index f05db6a..442109d 100644 --- a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/service/SchemaService.java +++ b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/service/SchemaService.java @@ -52,6 +52,38 @@ public interface SchemaService { */ EntityDefinition createEntity(EntityDefinition entity); + /** + * 迁移实体定义到数据库,保证 schema 与模型同步。 + *

+ * 表不存在时等价于 {@link #createEntity};表已存在(older 不为 null)时按旧定义做字段级 diff, + * 新增字段调用 {@link #createField},变更字段调用 {@link #modifyField}。 + * 兼容 failsafe 模式——createTable 失败被吞掉时仍能补列,不依赖 createEntity 抛异常。 + * + * @param newer 新的实体定义 + * @param older 已存在的旧实体定义(可为 null) + */ + default void migrateEntity(EntityDefinition newer, SchemaObject older) { + try { + createEntity(newer.clone()); + } catch (Exception e) { + // 表已存在(非 failsafe 模式会抛出),继续做字段级 diff + } + if (older instanceof EntityDefinition olderEntity) { + for (TypedField field : newer.getFields()) { + field.setModelName(newer.getName()); + try { + TypedField oldField = olderEntity.getField(field.getName()); + if (oldField == null) { + createField(field); + } else if (!field.equals(oldField)) { + modifyField(field); + } + } catch (Exception ignored) { + } + } + } + } + /** * 创建本地查询 * diff --git a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/Session.java b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/Session.java index 5d31dae..c0a3899 100644 --- a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/Session.java +++ b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/Session.java @@ -82,22 +82,7 @@ default boolean applyFML(String fmlString) { continue; } if (obj instanceof EntityDefinition newer) { - try { - schema().createEntity(newer.clone()); - } catch (Exception e) { - if (older instanceof EntityDefinition olderEntity) { - for (var field : newer.getFields()) { - try { - if (olderEntity.getField(field.getName()) == null) { - schema().createField(field); - } else if (!field.equals(olderEntity.getField(field.getName()))) { - schema().modifyField(field); - } - } catch (Exception ignored) { - } - } - } - } + schema().migrateEntity(newer, older); } else if (obj instanceof EnumDefinition newer) { try { schema().dropModel(newer.getName()); diff --git a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/SessionFactory.java b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/SessionFactory.java index 971c2ee..6d9448b 100644 --- a/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/SessionFactory.java +++ b/flexmodel-engine/flexmodel-core/src/main/java/dev/flexmodel/session/SessionFactory.java @@ -24,6 +24,7 @@ import java.io.InputStream; import java.sql.Connection; import java.util.*; +import java.util.stream.Collectors; /** * @author cjbi @@ -98,9 +99,12 @@ private void applyBuildItemSchemas(String schemaName) { } try (Session session = createFailsafeSession(schemaName)) { + // 先快照已注册的旧定义;migrateEntity 会覆盖 registry,必须在迁移前捕获 older 做字段 diff + Map existing = session.schema().listModels().stream() + .collect(Collectors.toMap(SchemaObject::getName, m -> m)); config.getSchema().forEach(obj -> { if (obj instanceof EntityDefinition e) { - session.schema().createEntity(e); + session.schema().migrateEntity(e, existing.get(e.getName())); } else if (obj instanceof EnumDefinition e) { session.schema().createEnum(e); } diff --git a/flexmodel-engine/flexmodel-graphql/src/test/java/dev/flexmodel/graphql/AbstractIntegrationTest.java b/flexmodel-engine/flexmodel-graphql/src/test/java/dev/flexmodel/graphql/AbstractIntegrationTest.java index 8880685..7294b61 100644 --- a/flexmodel-engine/flexmodel-graphql/src/test/java/dev/flexmodel/graphql/AbstractIntegrationTest.java +++ b/flexmodel-engine/flexmodel-graphql/src/test/java/dev/flexmodel/graphql/AbstractIntegrationTest.java @@ -23,7 +23,9 @@ public class AbstractIntegrationTest { @BeforeAll static void init() throws Exception { AgroalDataSourceConfigurationSupplier cfg = new AgroalDataSourceConfigurationSupplier(); - cfg.connectionPoolConfiguration().connectionFactoryConfiguration() + cfg.connectionPoolConfiguration() + .maxSize(5) + .connectionFactoryConfiguration() .jdbcUrl("jdbc:sqlite:file::memory:?cache=shared"); AgroalDataSource dataSource = AgroalDataSource.from(cfg); JdbcSchemaProvider jdbcSchemaProvider = new JdbcSchemaProvider("system", dataSource); @@ -36,7 +38,9 @@ static void init() throws Exception { @AfterAll static void destroy() { - session.close(); + if (session != null) { + session.close(); + } } } diff --git a/flexmodel-functions-runtime/deno.json b/flexmodel-functions-runtime/deno.json index 463a9ed..7db912c 100644 --- a/flexmodel-functions-runtime/deno.json +++ b/flexmodel-functions-runtime/deno.json @@ -14,7 +14,7 @@ "hono": "jsr:@hono/hono@^4.6.0", "@std/assert": "jsr:@std/assert@^1.0.0", "@std/testing": "jsr:@std/testing@^1.0.0", - "@flexmodel/sdk": "npm:@flexmodel/sdk@0.0.6", + "@flexmodel/sdk": "npm:@flexmodel/sdk@0.0.7", "jose": "npm:jose@^5.9.0" }, "compilerOptions": { diff --git a/flexmodel-functions-runtime/deno.lock b/flexmodel-functions-runtime/deno.lock index 601f659..e0f331a 100644 --- a/flexmodel-functions-runtime/deno.lock +++ b/flexmodel-functions-runtime/deno.lock @@ -1,7 +1,7 @@ { "version": "5", "specifiers": { - "jsr:@hono/hono@^4.6.0": "4.12.24", + "jsr:@hono/hono@^4.6.0": "4.12.27", "jsr:@std/assert@1": "1.0.19", "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/async@^1.4.0": "1.4.0", @@ -11,14 +11,12 @@ "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/path@^1.1.5": "1.1.5", "jsr:@std/testing@1": "1.0.19", - "npm:@flexmodel/sdk@0.0.6": "0.0.6", - "npm:jose@^5.9.0": "5.10.0", - "npm:jsonwebtoken@*": "9.0.3", - "npm:openai@*": "6.46.0" + "npm:@flexmodel/sdk@0.0.7": "0.0.7", + "npm:jose@^5.9.0": "5.10.0" }, "jsr": { - "@hono/hono@4.12.24": { - "integrity": "a74a40f06ae6704ddd0e8e576f6da9d34666c3e1e0f5412a2c1ff800ffd092f4" + "@hono/hono@4.12.27": { + "integrity": "3acc640592008e43f7048e8e53275203e0da84fbcd50201121195da7560583dc" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", @@ -60,102 +58,13 @@ } }, "npm": { - "@flexmodel/sdk@0.0.6": { - "integrity": "sha512-HIrln9cK0pSY23ZYMRxfIWjgqk8dXte3lN90wQCkYaI/kiThdHlvSFj32WEF+ZRL0Tlan+M+5kyMkv4KkXlJ6A==", - "tarball": "https://registry.npmmirror.com/@flexmodel/sdk/-/sdk-0.0.6.tgz" - }, - "buffer-equal-constant-time@1.0.1": { - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "tarball": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz" - }, - "ecdsa-sig-formatter@1.0.11": { - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dependencies": [ - "safe-buffer" - ], - "tarball": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" + "@flexmodel/sdk@0.0.7": { + "integrity": "sha512-hW3HgiTCb1sz7spXO8VGJDhhsUQmcuZHk+FAOVtdvCgC/lhhV00hTWu+TuCAgpTTB8jVEI0+6nlPNcTZht3laA==", + "tarball": "https://registry.npmmirror.com/@flexmodel/sdk/-/sdk-0.0.7.tgz" }, "jose@5.10.0": { "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "tarball": "https://registry.npmmirror.com/jose/-/jose-5.10.0.tgz" - }, - "jsonwebtoken@9.0.3": { - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "dependencies": [ - "jws", - "lodash.includes", - "lodash.isboolean", - "lodash.isinteger", - "lodash.isnumber", - "lodash.isplainobject", - "lodash.isstring", - "lodash.once", - "ms", - "semver" - ], - "tarball": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz" - }, - "jwa@2.0.1": { - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "dependencies": [ - "buffer-equal-constant-time", - "ecdsa-sig-formatter", - "safe-buffer" - ], - "tarball": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz" - }, - "jws@4.0.1": { - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "dependencies": [ - "jwa", - "safe-buffer" - ], - "tarball": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz" - }, - "lodash.includes@4.3.0": { - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "tarball": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz" - }, - "lodash.isboolean@3.0.3": { - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "tarball": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz" - }, - "lodash.isinteger@4.0.4": { - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "tarball": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz" - }, - "lodash.isnumber@3.0.3": { - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "tarball": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz" - }, - "lodash.isplainobject@4.0.6": { - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "tarball": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz" - }, - "lodash.isstring@4.0.1": { - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "tarball": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz" - }, - "lodash.once@4.1.1": { - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "tarball": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "tarball": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz" - }, - "openai@6.46.0": { - "integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==", - "tarball": "https://registry.npmmirror.com/openai/-/openai-6.46.0.tgz" - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "tarball": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz" - }, - "semver@7.8.5": { - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "bin": true, - "tarball": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz" } }, "workspace": { @@ -163,7 +72,7 @@ "jsr:@hono/hono@^4.6.0", "jsr:@std/assert@1", "jsr:@std/testing@1", - "npm:@flexmodel/sdk@0.0.6", + "npm:@flexmodel/sdk@0.0.7", "npm:jose@^5.9.0" ] } diff --git a/flexmodel-functions-runtime/src/router/functions.ts b/flexmodel-functions-runtime/src/router/functions.ts index 468ae0e..a539598 100644 --- a/flexmodel-functions-runtime/src/router/functions.ts +++ b/flexmodel-functions-runtime/src/router/functions.ts @@ -29,41 +29,41 @@ const router = new Hono<{ Variables: { edgeAuth: EdgeAuthContext } }>(); // ---- POST /functions/deploy ---- // Write source files to disk + generate wrapper + register metadata router.post("/functions/deploy", async (c) => { - try { - const body: DeployRequest = await c.req.json(); - - if (!body.projectId || !body.name || !body.functionId || !body.sourceFiles) { - return c.json( - { success: false, error: "Missing required fields: projectId, name, functionId, sourceFiles" }, - 400, - ); - } + try { + const body: DeployRequest = await c.req.json(); + + if (!body.projectId || !body.name || !body.functionId || !body.sourceFiles) { + return c.json( + {success: false, error: "Missing required fields: projectId, name, functionId, sourceFiles"}, + 400, + ); + } - await registry.deploy(body); + await registry.deploy(body); - return c.json({ success: true, name: body.name }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error("[functions] Deploy error:", message); - return c.json({ success: false, error: message }, 500); - } + return c.json({success: true, name: body.name}); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("[functions] Deploy error:", message); + return c.json({success: false, error: message}, 500); + } }); // ---- DELETE /functions/:projectId/:name ---- // Remove function from registry + delete disk directory router.delete("/functions/:projectId/:name", async (c) => { - const { projectId, name } = c.req.param(); + const {projectId, name} = c.req.param(); - if (!registry.has(projectId, name)) { - return c.json( - { success: false, error: `Function not found: ${projectId}:${name}` }, - 404, - ); - } + if (!registry.has(projectId, name)) { + return c.json( + {success: false, error: `Function not found: ${projectId}:${name}`}, + 404, + ); + } - await registry.delete(projectId, name); + await registry.delete(projectId, name); - return c.json({ success: true }); + return c.json({success: true}); }); // ---- POST /functions/:projectId/:name/invoke ---- @@ -71,14 +71,14 @@ router.delete("/functions/:projectId/:name", async (c) => { // authToken 和 invokeId 通过 HTTP headers 传入(由 Java 服务端设置) // 请求体直接作为函数的 Request body router.post("/functions/:projectId/:name/invoke", async (c) => { - const { projectId, name } = c.req.param(); + const {projectId, name} = c.req.param(); - if (!registry.has(projectId, name)) { - return c.json( - { success: false, error: `Function not found: ${projectId}:${name}` }, - 404, - ); - } + if (!registry.has(projectId, name)) { + return c.json( + {success: false, error: `Function not found: ${projectId}:${name}`}, + 404, + ); + } // 从 headers 提取服务端注入的元数据 const authToken = c.req.header("x-flexmodel-auth-token"); @@ -98,33 +98,33 @@ router.post("/functions/:projectId/:name/invoke", async (c) => { // 请求体直接作为函数输入(不再嵌套在 input 字段中) const body = await c.req.json().catch(() => null); - try { - const result = await invokeFunction(projectId, name, body, authToken, invokeId, forwardedHeaders); - - // Return function result directly as HTTP response - // _meta is passed via response header for debug/observability - const res = c.newResponse( - typeof result.body === "string" ? result.body : JSON.stringify(result.body ?? null), - result.status as StatusCode, - { - ...result.headers, - "content-type": result.headers["content-type"] ?? "application/json", - "x-function-meta": JSON.stringify(result._meta), - }, - ); - return res; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - const isTimeout = message.includes("timed out"); - const status = isTimeout ? 504 : 500; - const errorMeta = {executionTimeMs: 0, invokeId}; - - return c.json( - { error: isTimeout ? "Function execution timed out" : message }, - status, - { "x-function-meta": JSON.stringify(errorMeta) }, - ); - } + try { + const result = await invokeFunction(projectId, name, body, authToken, invokeId, forwardedHeaders); + + // Return function result directly as HTTP response + // _meta is passed via response header for debug/observability + const res = c.newResponse( + typeof result.body === "string" ? result.body : JSON.stringify(result.body ?? null), + result.status as StatusCode, + { + ...result.headers, + "content-type": result.headers["content-type"] ?? "application/json", + "x-function-meta": JSON.stringify(result._meta), + }, + ); + return res; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const isTimeout = message.includes("timed out"); + const status = isTimeout ? 504 : 500; + const errorMeta = {executionTimeMs: 0, invokeId}; + + return c.json( + {error: isTimeout ? "Function execution timed out" : message}, + status, + {"x-function-meta": JSON.stringify(errorMeta)}, + ); + } }); // ============================================================ diff --git a/flexmodel-functions-runtime/src/runner/registry.ts b/flexmodel-functions-runtime/src/runner/registry.ts index 30739e7..bca0baf 100644 --- a/flexmodel-functions-runtime/src/runner/registry.ts +++ b/flexmodel-functions-runtime/src/runner/registry.ts @@ -179,7 +179,7 @@ self.addEventListener("message", async (e) => { function generateFunctionDenoJson(): string { return JSON.stringify({ imports: { - "@flexmodel/sdk": "npm:@flexmodel/sdk@0.0.4", + "@flexmodel/sdk": "npm:@flexmodel/sdk@0.0.7", }, }, null, 2); } diff --git a/flexmodel-functions-runtime/src/runner/worker_test.ts b/flexmodel-functions-runtime/src/runner/worker_test.ts index afa4669..fe72cc8 100644 --- a/flexmodel-functions-runtime/src/runner/worker_test.ts +++ b/flexmodel-functions-runtime/src/runner/worker_test.ts @@ -14,57 +14,57 @@ import {cleanupTempDirs, makeTempDir, restoreEnv, setEnv,} from "../test_helpers // Helper to quickly deploy a test function async function deployTestFunction( - projectId: string, - name: string, - code: string, - timeout = 5, + projectId: string, + name: string, + code: string, + timeout = 5, ) { - await registry.deploy({ - projectId, - functionId: `${projectId}-${name}-id`, - name, - sourceFiles: { "index.ts": code }, - timeout, - }); + await registry.deploy({ + projectId, + functionId: `${projectId}-${name}-id`, + name, + sourceFiles: {"index.ts": code}, + timeout, + }); } Deno.test("invokeFunction runs a simple function returning plain object", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p1", - "jsonFn", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p1", + "jsonFn", + ` export default async (req: Request) => { return { answer: 42 }; }; `, - ); + ); - const result = await invokeFunction("wk-p1", "jsonFn", {}); + const result = await invokeFunction("wk-p1", "jsonFn", {}); - assertEquals(result.status, 200); - assertEquals(result.body, { answer: 42 }); - assertEquals(result._meta.executionTimeMs >= 0, true); + assertEquals(result.status, 200); + assertEquals(result.body, {answer: 42}); + assertEquals(result._meta.executionTimeMs >= 0, true); - await registry.delete("wk-p1", "jsonFn"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + await registry.delete("wk-p1", "jsonFn"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction runs a function returning a Response object", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p2", - "responseFn", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p2", + "responseFn", + ` export default async (req: Request) => { return new Response(JSON.stringify({type:"response"}), { status: 201, @@ -72,141 +72,142 @@ Deno.test("invokeFunction runs a function returning a Response object", async () }); }; `, - ); + ); - const result = await invokeFunction("wk-p2", "responseFn", {}); + const result = await invokeFunction("wk-p2", "responseFn", {}); - assertEquals(result.status, 201); - assertEquals((result.body as Record).type, "response"); - assertEquals(result.headers["x-custom"], "yes"); + assertEquals(result.status, 201); + assertEquals((result.body as Record).type, "response"); + assertEquals(result.headers["x-custom"], "yes"); - await registry.delete("wk-p2", "responseFn"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + await registry.delete("wk-p2", "responseFn"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction enforces timeout", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p3", - "slow", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p3", + "slow", + ` export default async (req: Request) => { await new Promise(r => setTimeout(r, 10000)); return { ok: true }; }; `, - 1, // 1 second timeout - ); - - await assertRejects( - () => - invokeFunction("wk-p3", "slow", {}), - Error, - "timed out", - ); - - await registry.delete("wk-p3", "slow"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + 1, // 1 second timeout + ); + + await assertRejects( + () => + invokeFunction("wk-p3", "slow", {}), + Error, + "timed out", + ); + + await registry.delete("wk-p3", "slow"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction propagates runtime errors", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p4", - "boom", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p4", + "boom", + ` export default async (req: Request) => { throw new Error("intentional boom"); }; `, - ); - - await assertRejects( - () => - invokeFunction("wk-p4", "boom", {}), - Error, - "intentional boom", - ); - - await registry.delete("wk-p4", "boom"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + ); + + await assertRejects( + () => + invokeFunction("wk-p4", "boom", {}), + Error, + "intentional boom", + ); + + await registry.delete("wk-p4", "boom"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction fails when function directory is missing", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p5", - "ghost", - `export default async (req: Request) => ({ok:true});`, - ); - - // Manually nuke directory to simulate corruption - const meta = registry.get("wk-p5", "ghost")!; - await Deno.remove(meta.functionDir, { recursive: true }); - - await assertRejects( - () => - invokeFunction("wk-p5", "ghost", {}), - Error, - "Function directory not found", - ); - - // metadata still exists but dir is gone; clean registry entry - // registry.delete would try to remove the already-removed dir, which is fine - await registry.delete("wk-p5", "ghost"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p5", + "ghost", + `export default async (req: Request) => ({ok:true});`, + ); + + // Manually nuke directory to simulate corruption + const meta = registry.get("wk-p5", "ghost")!; + await Deno.remove(meta.functionDir, {recursive: true}); + + await assertRejects( + () => + invokeFunction("wk-p5", "ghost", {}), + Error, + "Function directory not found", + ); + + // metadata still exists but dir is gone; clean registry entry + // registry.delete would try to remove the already-removed dir, which is fine + await registry.delete("wk-p5", "ghost"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction runs user code that calls SDK directly", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - // SDK inside the Worker calls fetch directly (not via postMessage), - // so a globalThis.fetch mock in the main process won't be visible. - // Spin up a real local mock server that the Worker can hit. - const mockServer = Deno.serve({ port: 0 }, (req) => { - const url = new URL(req.url); - // SDK calls /api/projects/:pid/models/:model/records - if (url.pathname.includes("/api/projects/wk-p6/models/User/records")) { - return new Response( - JSON.stringify({ list: [{ id: "u1" }], total: 1 }), - { headers: { "content-type": "application/json" } }, - ); - } - return new Response("{}", { status: 404 }); - }); - const mockPort = mockServer.addr.port; - - // Point SDK's baseURL at the mock server - setEnv("FLEXMODEL_JAVA_HOST", "localhost"); - setEnv("FLEXMODEL_JAVA_PORT", String(mockPort)); - - try { - await deployTestFunction( - "wk-p6", - "sdkUser", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + // SDK inside the Worker calls fetch directly (not via postMessage), + // so a globalThis.fetch mock in the main process won't be visible. + // Spin up a real local mock server that the Worker can hit. + const mockServer = Deno.serve({port: 0}, (req) => { + const url = new URL(req.url); + // SDK calls /api/projects/:pid/models/:model/records + // SDK open client calls /api/open/:pid/models/:model/records + if (url.pathname.includes("/api/open/wk-p6/models/User/records")) { + return new Response( + JSON.stringify({list: [{id: "u1"}], total: 1}), + {headers: {"content-type": "application/json"}}, + ); + } + return new Response("{}", {status: 404}); + }); + const mockPort = mockServer.addr.port; + + // Point SDK's baseURL at the mock server + setEnv("FLEXMODEL_JAVA_HOST", "localhost"); + setEnv("FLEXMODEL_JAVA_PORT", String(mockPort)); + + try { + await deployTestFunction( + "wk-p6", + "sdkUser", + ` import { flexmodelClient } from "@flexmodel/sdk"; export default async (req: Request) => { @@ -215,30 +216,30 @@ Deno.test("invokeFunction runs user code that calls SDK directly", async () => { return { users }; }; `, - ); - - const result = await invokeFunction("wk-p6", "sdkUser", {}, "test-token"); - assertEquals(result.status, 200); - const body = result.body as Record; - assertEquals(body.users !== undefined, true); - - await registry.delete("wk-p6", "sdkUser"); - } finally { - await mockServer.shutdown(); - await cleanupTempDirs(); - restoreEnv(); - } + ); + + const result = await invokeFunction("wk-p6", "sdkUser", {}, "test-token"); + assertEquals(result.status, 200); + const body = result.body as Record; + assertEquals(body.users !== undefined, true); + + await registry.delete("wk-p6", "sdkUser"); + } finally { + await mockServer.shutdown(); + await cleanupTempDirs(); + restoreEnv(); + } }); Deno.test("invokeFunction passes Request with accessible body and headers", async () => { - const tempDir = makeTempDir(); - setEnv("FUNCTIONS_DIR", tempDir); - - try { - await deployTestFunction( - "wk-p7", - "requestInspector", - ` + const tempDir = makeTempDir(); + setEnv("FUNCTIONS_DIR", tempDir); + + try { + await deployTestFunction( + "wk-p7", + "requestInspector", + ` export default async (req: Request) => { const body = await req.json(); return { @@ -252,25 +253,25 @@ Deno.test("invokeFunction passes Request with accessible body and headers", asyn }; }; `, - ); - - const invokeId = "test-invoke-123"; - const result = await invokeFunction("wk-p7", "requestInspector", {message: "hello"}, undefined, invokeId); - - assertEquals(result.status, 200); - const body = result.body as Record; - assertEquals(body.method, "POST"); - assertEquals(body.projectId, "wk-p7"); - assertEquals(body.invokeId, invokeId); - assertEquals(body.functionName, "requestInspector"); - assertEquals(body.contentType, "application/json"); - assertEquals((body.echo as Record).message, "hello"); - - await registry.delete("wk-p7", "requestInspector"); - } finally { - await cleanupTempDirs(); - restoreEnv(); - } + ); + + const invokeId = "test-invoke-123"; + const result = await invokeFunction("wk-p7", "requestInspector", {message: "hello"}, undefined, invokeId); + + assertEquals(result.status, 200); + const body = result.body as Record; + assertEquals(body.method, "POST"); + assertEquals(body.projectId, "wk-p7"); + assertEquals(body.invokeId, invokeId); + assertEquals(body.functionName, "requestInspector"); + assertEquals(body.contentType, "application/json"); + assertEquals((body.echo as Record).message, "hello"); + + await registry.delete("wk-p7", "requestInspector"); + } finally { + await cleanupTempDirs(); + restoreEnv(); + } }); // ============================================================ diff --git a/flexmodel-sdks b/flexmodel-sdks index 0ae6993..cd01e13 160000 --- a/flexmodel-sdks +++ b/flexmodel-sdks @@ -1 +1 @@ -Subproject commit 0ae69930a9b698963b747053e308d2844a1ee89e +Subproject commit cd01e135a16de50c0926d2f1d1685b874b9297c4 diff --git a/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/ApiKeyResponse.java b/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/ApiKeyResponse.java index 6ddefcf..7552320 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/ApiKeyResponse.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/ApiKeyResponse.java @@ -11,7 +11,6 @@ public class ApiKeyResponse { private String id; private String name; private String keyPrefix; - private String keyType; private String projectIds; private boolean readOnly; private LocalDateTime expiresAt; @@ -27,7 +26,6 @@ public static ApiKeyResponse fromEntity(dev.flexmodel.codegen.entity.AuthApiKey resp.setId(entity.getId()); resp.setName(entity.getName()); resp.setKeyPrefix(entity.getKeyPrefix()); - resp.setKeyType(entity.getKeyType()); resp.setProjectIds(entity.getProjectIds()); resp.setReadOnly(entity.getReadOnly()); resp.setExpiresAt(entity.getExpiresAt()); diff --git a/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/CreateApiKeyRequest.java b/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/CreateApiKeyRequest.java index 5a27433..84d0011 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/CreateApiKeyRequest.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/auth/dto/CreateApiKeyRequest.java @@ -2,7 +2,6 @@ public record CreateApiKeyRequest( String name, - String keyType, String projectIds, boolean readOnly ) { diff --git a/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyGenerator.java b/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyGenerator.java index a915bd7..2988f21 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyGenerator.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyGenerator.java @@ -9,7 +9,8 @@ /** * API Key 生成工具。 - * 生成格式:fm_ak_{type}_{random40chars} + * 生成格式:fm_ak_{scope}_{random40chars} + * 生成格式:fm_ak_{random40chars} * 存储 SHA-256 哈希,不存原文。 * *

{@link SecureRandom} 实例为方法内局部变量, @@ -24,18 +25,16 @@ public record GeneratedKey(String plainText, String hash, String prefix) { /** * 生成一个新的 API Key。 - * - * @param keyType anon / service / custom * @return 包含明文、SHA-256 哈希和前缀的 GeneratedKey */ - public static GeneratedKey generate(String keyType) { + public static GeneratedKey generate() { SecureRandom random = new SecureRandom(); StringBuilder sb = new StringBuilder(40); for (int i = 0; i < 40; i++) { sb.append(CHARS.charAt(random.nextInt(CHARS.length()))); } String randomPart = sb.toString(); - String plainText = "fm_ak_" + keyType + "_" + randomPart; + String plainText = "fm_ak_" + randomPart; String hash = sha256(plainText); String prefix = plainText.substring(0, Math.min(plainText.length(), 16)); return new GeneratedKey(plainText, hash, prefix); diff --git a/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyService.java b/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyService.java index 66a2a0c..19f8df1 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyService.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/auth/service/ApiKeyService.java @@ -28,14 +28,12 @@ public List listAll() { * 创建 API Key,返回包含明文 key 的响应(仅此一次)。 */ public ApiKeyResponse create(CreateApiKeyRequest request) { - String keyType = request.keyType() != null ? request.keyType() : "custom"; - ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(keyType); + ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(); AuthApiKey entity = new AuthApiKey(); entity.setName(request.name()); entity.setKeyHash(generated.hash()); entity.setKeyPrefix(generated.prefix()); - entity.setKeyType(keyType); entity.setProjectIds(request.projectIds()); entity.setReadOnly(request.readOnly()); @@ -54,7 +52,7 @@ public ApiKeyResponse regenerate(String id) { if (existing == null) { throw new IllegalArgumentException("API Key not found: " + id); } - ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(existing.getKeyType()); + ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(); existing.setKeyHash(generated.hash()); existing.setKeyPrefix(generated.prefix()); apiKeyRepository.save(existing); diff --git a/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java b/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java index 4858b69..99b0c83 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java @@ -23,42 +23,43 @@ public interface FlexmodelConfig extends Serializable { /** * Project base domain, used for CORS and URL construction in subdomain routing mode. + * 空表示 path 模式;配置了真实域名则走 subdomain 模式。 */ @WithName("project-base-domain") - @WithDefault("localhost") - String projectBaseDomain(); + Optional projectBaseDomain(); /** - * Routing mode: "path" or "subdomain". - * In "path" mode, multi-tenant resources are accessed via path segments - * (e.g., /pages/{projectId}, /functions/{projectId}/{name}). - * In "subdomain" mode, they are accessed via subdomain - * (e.g., {projectId}.{projectBaseDomain}, {projectId}.{projectBaseDomain}/functions/{name}). + * 路由模式由 {@link #projectBaseDomain()} 自动推断,无需显式配置: + *

    + *
  • 配置了域名(非空)→ subdomain 模式
  • + *
  • 未配置(空)→ path 模式
  • + *
*/ - @WithName("project-routing-mode") - @WithDefault("path") - String projectRoutingMode(); + default boolean isSubdomainRouting() { + return projectBaseDomain().map(d -> !d.isBlank()).orElse(false); + } /** - * Derive the edge function URL based on routing mode and project base domain. - * - path mode: /functions/{{projectId}}/{{name}} - * - subdomain mode: https://{{projectId}}.{projectBaseDomain}/functions/{{name}} + * 推导边缘函数调用 URL(统一经 Java 代理端点 /open/{projectId}/functions/{name}/invoke)。 + * - path 模式: /api/open/{{projectId}}/functions/{{name}}/invoke + * - subdomain 模式: https://{{projectId}}.{projectBaseDomain}/api/open/{{projectId}}/functions/{{name}}/invoke */ default String edgeUrlTemplate() { - if ("subdomain".equals(projectRoutingMode())) { - return "https://{{projectId}}." + projectBaseDomain() + "/functions/{{name}}"; + if (isSubdomainRouting()) { + return "https://{{projectId}}." + projectBaseDomain().orElse("") + + "/api/open/{{projectId}}/functions/{{name}}/invoke"; } - return "/functions/{{projectId}}/{{name}}"; + return "/api/open/{{projectId}}/functions/{{name}}/invoke"; } /** - * Derive the pages URL based on routing mode and project base domain. - * - path mode: /pages/{{projectId}} - * - subdomain mode: https://{{projectId}}.{projectBaseDomain} + * 推导 Pages 站点 URL。 + * - path 模式: /pages/{{projectId}} + * - subdomain 模式: https://{{projectId}}.{projectBaseDomain}(子域名根即站点) */ default String pagesUrlTemplate() { - if ("subdomain".equals(projectRoutingMode())) { - return "https://{{projectId}}." + projectBaseDomain(); + if (isSubdomainRouting()) { + return "https://{{projectId}}." + projectBaseDomain().orElse(""); } return "/pages/{{projectId}}"; } diff --git a/flexmodel-server/src/main/java/dev/flexmodel/common/config/web/filter/AuthFilter.java b/flexmodel-server/src/main/java/dev/flexmodel/common/config/web/filter/AuthFilter.java index d99631d..1c82dce 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/common/config/web/filter/AuthFilter.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/common/config/web/filter/AuthFilter.java @@ -28,10 +28,14 @@ import java.util.*; /** - * 认证过滤器。 + * 认证过滤器 — 控制面/数据面分层鉴权。 *

- * 认证链:PermitAll -> 临时 Token(svc:runtime) -> 项目 Provider(OIDC/Function) -> API Key(fm_ak_ 前缀) -> 系统 JWT -> - * 401 + * 根据请求路径区分 API surface: + *

    + *
  • OPEN surface(路径以 {@code open/} 开头):允许 open scope API Key + 项目 IdP + 匿名(无 IdP 时)
  • + *
  • ADMIN surface(其余路径):允许系统 JWT + admin scope API Key
  • + *
+ * 认证链在 surface 内短路,跨 surface 的凭证一律拒绝。 * * @author cjbi */ @@ -58,10 +62,8 @@ public class AuthFilter implements ContainerRequestFilter, ContainerResponseFilt @Override public void filter(ContainerRequestContext requestContext) throws IOException { String path = requestContext.getUriInfo().getPath(); - boolean isFlexmodelPath = path.startsWith("/"); - if (!isFlexmodelPath) { - return; - } + // Normalize: remove leading slash for consistent prefix matching + String normalizedPath = path != null ? path.replaceFirst("^/+", "") : ""; // 1. PermitAll -> 直接放行 PermitAll permitAll = resourceInfo.getResourceMethod().getAnnotation(PermitAll.class); @@ -72,31 +74,75 @@ public void filter(ContainerRequestContext requestContext) throws IOException { // 2. 提取 Bearer token String accessToken = Objects.toString(requestContext.getHeaderString("Authorization"), "") .replaceFirst("Bearer ", "").trim(); - if (accessToken.isEmpty()) { - // 公开 Bucket 的匿名读访问(GET/HEAD 下载/预览/元数据),支持"复制访问链接"在浏览器中直接打开 - if (isAnonymousPublicBucketRead(requestContext)) { + + // 3. 根据路径判断 API surface + boolean isOpenSurface = normalizedPath.startsWith("open/"); + + String projectId = requestContext.getUriInfo().getPathParameters().getFirst("projectId"); + + if (isOpenSurface) { + // ---- OPEN surface: API Key (open scope) + system JWT + IdP + anonymous ---- + if (accessToken.isEmpty()) { + // Try IdP first (anonymous allowed if no IdP configured) + if (tryProjectProviders(accessToken, requestContext, projectId)) { + return; + } + // Public Bucket anonymous read + if (isAnonymousPublicBucketRead(requestContext)) { + return; + } + throw new AuthException("Token is missing"); + } + + // API Key (must be open scope) + if (accessToken.startsWith("fm_ak_")) { + if (tryOpenApiKey(accessToken, requestContext, projectId)) { + return; + } + throw new AuthException("Invalid or unauthorized API key"); + } + + // System JWT (service accounts, e.g. Deno runtime callback) + if (trySystemJwt(accessToken, requestContext, projectId)) { return; } - throw new AuthException("Token is missing"); - } - String projectId = requestContext.getUriInfo().getPathParameters().getFirst("projectId"); + // IdP token + if (projectId != null && tryProjectProviders(accessToken, requestContext, projectId)) { + return; + } - // 3. 认证链: 系统 JWT -> API Key -> IdP - if (trySystemJwt(accessToken, requestContext, projectId)) { - return; - } - if (accessToken.startsWith("fm_ak_") && tryApiKey(accessToken, requestContext, projectId)) { - return; - } - if (projectId != null && tryProjectProviders(accessToken, requestContext, projectId)) { - return; - } + // Public Bucket anonymous read fallback + if (isAnonymousPublicBucketRead(requestContext)) { + return; + } + throw new AuthException("Invalid token"); + } else { + // ---- ADMIN surface: system JWT + admin scope API Key ---- + if (accessToken.isEmpty()) { + // Public Bucket anonymous read (admin surface still allows this) + if (isAnonymousPublicBucketRead(requestContext)) { + return; + } + throw new AuthException("Token is missing"); + } - // 4. 全部失败 -> 401 - throw new AuthException("Invalid token"); + // System JWT + if (trySystemJwt(accessToken, requestContext, projectId)) { + return; + } + // API Key (must be admin scope) + if (accessToken.startsWith("fm_ak_") && tryAdminApiKey(accessToken, requestContext, projectId)) { + return; + } + throw new AuthException("Invalid token"); + } } + // ============================================================ + // ADMIN surface auth methods + // ============================================================ + /** * 尝试系统 JWT 验证(管理后台用户)。 */ @@ -114,14 +160,13 @@ private boolean trySystemJwt(String token, ContainerRequestContext requestContex } /** - * 尝试 API Key 验证(fm_ak_ 前缀)。 + * 尝试 API Key 验证(admin 接口面)。 */ - private boolean tryApiKey(String token, ContainerRequestContext requestContext, String projectId) { + private boolean tryAdminApiKey(String token, ContainerRequestContext requestContext, String projectId) { AuthApiKey apiKey = apiKeyService.validate(token); if (apiKey == null) { return false; } - // 系统级 Key(project_id 为空):检查 project_ids 白名单 if (!isProjectAllowed(apiKey, projectId)) { return false; } @@ -129,6 +174,29 @@ private boolean tryApiKey(String token, ContainerRequestContext requestContext, return true; } + // ============================================================ + // OPEN surface auth methods + // ============================================================ + + /** + * 尝试 API Key 验证(open 接口面)。 + */ + private boolean tryOpenApiKey(String token, ContainerRequestContext requestContext, String projectId) { + AuthApiKey apiKey = apiKeyService.validate(token); + if (apiKey == null) { + return false; + } + if (!isProjectAllowed(apiKey, projectId)) { + return false; + } + fillSessionContextForApiKey(requestContext, apiKey, projectId); + return true; + } + + // ============================================================ + // Shared auth methods + // ============================================================ + /** * 检查系统级 API Key 是否允许访问指定项目。 */ @@ -149,6 +217,15 @@ private boolean isProjectAllowed(AuthApiKey apiKey, String projectId) { private boolean tryProjectProviders(String token, ContainerRequestContext requestContext, String projectId) { List configs = authProviderConfigService.listByProject(projectId); if (configs == null || configs.isEmpty()) { + // No providers configured → anonymous access (open surface only) + if (token == null || token.isBlank()) { + fillSessionContextForAnonymous(requestContext, projectId); + return true; + } + return false; + } + + if (token == null || token.isBlank()) { return false; } @@ -197,6 +274,10 @@ private AuthContext buildAuthContext(String projectId, String token, ContainerRe return ctx; } + // ============================================================ + // Session context fillers + // ============================================================ + /** * 系统 JWT 认证 -> 填充上下文(管理后台用户)。 */ @@ -247,12 +328,24 @@ private void fillSessionContextForProvider(ContainerRequestContext requestContex requestContext.setProperty("projectId", projectId); } + /** + * 匿名访问 -> 填充上下文(仅 open surface,无 IdP 配置时)。 + */ + private void fillSessionContextForAnonymous(ContainerRequestContext requestContext, String projectId) { + if (projectId != null) { + Project project = projectService.findProject(projectId); + if (project == null) { + throw new AuthException("Project not found"); + } + sessionContext.setProjectId(projectId); + sessionContext.setProjectDatabaseName(projectService.resolveDatabaseName(projectId)); + } + sessionContext.setUserId("anonymous"); + requestContext.setProperty("projectId", projectId); + } + /** * 判断匿名请求是否可访问公开 Bucket 的对象读接口(GET/HEAD)。 - *

- * 仅当目标 Bucket 的 visibility 为 PUBLIC 时放行,用于支持"复制访问链接"在浏览器中直接打开; - * 仅放行指向具体对象(下载/HEAD/元数据)的 GET/HEAD;目录列表(/objects 无对象路径)不公开。 - * PRIVATE / AUTHENTICATED 及写操作(PUT/DELETE)始终要求认证。 */ private boolean isAnonymousPublicBucketRead(ContainerRequestContext requestContext) { String method = requestContext.getMethod(); @@ -260,7 +353,7 @@ private boolean isAnonymousPublicBucketRead(ContainerRequestContext requestConte return false; } String path = requestContext.getUriInfo().getPath(); - if (path == null || !path.matches("(?i)^/?projects/[^/]+/buckets/[^/]+/objects/.+")) { + if (path == null || !path.matches("(?i)^/?(?:projects|open)/[^/]+/buckets/[^/]+/objects/.+")) { return false; } String projectId = requestContext.getUriInfo().getPathParameters().getFirst("projectId"); @@ -274,7 +367,6 @@ private boolean isAnonymousPublicBucketRead(ContainerRequestContext requestConte .filter(BucketVisibility.PUBLIC::equals) .isPresent(); } catch (Exception e) { - // Bucket / 项目不存在或解析失败时按非公开处理,回退到标准认证流程 return false; } } @@ -283,7 +375,6 @@ private boolean isAnonymousPublicBucketRead(ContainerRequestContext requestConte public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { // CDI @RequestScoped 自动管理生命周期,无需手动 clear - // SessionContext 在请求结束时由 CDI 自动销毁 } } diff --git a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java index 3647988..842b797 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java @@ -77,21 +77,4 @@ public Response invoke(@PathParam("projectId") String projectId, return builder.build(); } - - /** - * Sign an invoke-token for edge function direct invocation. - * - *

The frontend uses this token to directly call the Deno Runtime at the URL - * defined by {@code flexmodel.edge-url-template}, bypassing the Java server. - * - * @param projectId project ID - * @param name function name - * @return InvokeTokenResponse containing invoke-token and runtime URL - */ - @POST - @Path("/{name}/invoke-token") - public InvokeTokenResponse invokeToken(@PathParam("projectId") String projectId, - @PathParam("name") String name) { - return functionService.signInvokeToken(projectId, name); - } } diff --git a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClient.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClient.java index 8efea61..80fe477 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClient.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClient.java @@ -28,6 +28,7 @@ public interface FunctionRuntimeClient { @POST @Path("/{projectId}/{name}/invoke") + @Consumes(MediaType.WILDCARD) Response invoke(@PathParam("projectId") String projectId, @PathParam("name") String name, @HeaderParam("x-flexmodel-auth-token") String authToken, diff --git a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClientHeadersFactory.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClientHeadersFactory.java index 6dfbebc..33328e0 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClientHeadersFactory.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionRuntimeClientHeadersFactory.java @@ -24,7 +24,11 @@ public class FunctionRuntimeClientHeadersFactory implements ClientHeadersFactory { private static final List EXCLUDED_HEADERS = List.of( - "host", "content-length", "transfer-encoding", "connection" + "host", "content-length", "transfer-encoding", "connection", + // accept-encoding 是传输协商头,透传会导致 Deno serve 自动 gzip/br 压缩响应, + // 进而触发 Quarkus REST 客户端 @Consumes 严格匹配失败(content-type mismatch)。 + // Java↔Deno 是内部链路,不需要压缩;浏览器侧的压缩由上层服务处理。 + "accept-encoding" ); @Override diff --git a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionService.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionService.java index 23da9a7..ca58c82 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionService.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionService.java @@ -5,8 +5,6 @@ import dev.flexmodel.auth.service.InternalTokenService; import dev.flexmodel.codegen.entity.Function; import dev.flexmodel.codegen.entity.FunctionTemplate; -import dev.flexmodel.common.FlexmodelConfig; -import dev.flexmodel.common.config.web.jwt.JwtService; import dev.flexmodel.common.dto.PageDTO; import dev.flexmodel.functions.dto.*; import dev.flexmodel.query.Expressions; @@ -16,7 +14,6 @@ import jakarta.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; -import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -42,15 +39,9 @@ public class FunctionService { @Inject FunctionInvoker functionInvoker; - @Inject - JwtService jwtService; - @Inject InternalTokenService internalTokenService; - @Inject - FlexmodelConfig config; - @Inject ObjectMapper objectMapper; @@ -191,58 +182,6 @@ public Response invoke(String projectId, String name, Object body) { return response; } - // ============================================================ - // Edge Function — Invoke Token - // ============================================================ - - /** - * Sign an invoke-token for edge function direct invocation. - * - *

The invoke-token is a JWT signed with "svc:invoke" + jwtSecret (5-minute TTL), - * containing projectId, functionName, authToken (for SDK callback), and invokeId. - * The frontend uses this token to directly call the Deno Runtime at the URL - * defined by {@code flexmodel.edge-url-template}. - * - * @param projectId project ID - * @param name function name - * @return InvokeTokenResponse containing the token and runtime URL - */ - public InvokeTokenResponse signInvokeToken(String projectId, String name) { - Function fn = functionRepository.findByName(projectId, name); - if (fn == null) { - throw new FunctionException("Function not found: " + name); - } - - // 签发 Runtime 回调专用 JWT(SDK 在 Worker 内回调 Java API 时使用) - String authToken = internalTokenService.signToken(projectId); - // 生成本次调用的唯一ID - String invokeId = UUID.randomUUID().toString(); - - // 签发 invoke-token(密钥 = "svc:invoke" + jwtSecret,与系统用户 JWT 和 runtime JWT 不同) - String invokeToken = jwtService.sign( - "svc:invoke", - Map.of( - "projectId", projectId, - "functionName", name, - "authToken", authToken, - "invokeId", invokeId - ), - Duration.ofMinutes(5) - ); - - // 构造边缘函数 URL(使用模板替换) - String runtimeUrl = config.edgeUrlTemplate() - .replace("{{projectId}}", projectId) - .replace("{{name}}", name); - - log.info("Invoke-token signed for {}:{}", projectId, name); - - return InvokeTokenResponse.builder() - .invokeToken(invokeToken) - .runtimeUrl(runtimeUrl) - .build(); - } - // ============================================================ // Private Helpers // ============================================================ diff --git a/flexmodel-server/src/main/java/dev/flexmodel/functions/dto/InvokeTokenResponse.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/dto/InvokeTokenResponse.java deleted file mode 100644 index 6365813..0000000 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/dto/InvokeTokenResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.flexmodel.functions.dto; - -import lombok.Builder; -import lombok.Data; - -/** - * Response for the invoke-token endpoint. - * Contains the JWT invoke-token and the edge runtime URL for direct frontend invocation. - * - * @author cjbi - */ -@Data -@Builder -public class InvokeTokenResponse { - - /** - * JWT invoke-token (signed with "svc:invoke" + jwtSecret, 5-minute TTL) - */ - private String invokeToken; - - /** - * Edge runtime URL, constructed from flexmodel.edge-url-template - */ - private String runtimeUrl; -} diff --git a/flexmodel-server/src/main/java/dev/flexmodel/open/OpenDataResource.java b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenDataResource.java new file mode 100644 index 0000000..2a46be4 --- /dev/null +++ b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenDataResource.java @@ -0,0 +1,165 @@ +package dev.flexmodel.open; + +import dev.flexmodel.common.SessionContext; +import dev.flexmodel.common.authz.PermissionHelper; +import dev.flexmodel.common.dto.PageDTO; +import dev.flexmodel.data.DataService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Open API — 数据记录 CRUD。 + *

+ * 路径前缀 {@code /open/{projectId}/models/{modelName}/records}, + * 面向终端用户(IdP 用户 / open scope API Key)。 + * 管理操作(建模型、改 schema)不在 open 路由。 + * + * @author cjbi + */ +@ApplicationScoped +@Path("/open/{projectId}/models/{modelName}/records") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class OpenDataResource { + + private static final int MAX_BATCH_SIZE = 200; + + @Inject + DataService dataService; + + @Inject + SessionContext sessionContext; + + @GET + public PageDTO> findPagingRecords( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + @QueryParam("page") @DefaultValue("1") int page, + @QueryParam("size") @DefaultValue("15") int size, + @QueryParam("filter") String filter, + @QueryParam("expand") List expand, + @QueryParam("sort") String sort + ) { + requirePermission("data:" + modelName + ":view"); + return dataService.findPagingRecords(projectId, modelName, page, size, filter, sort, expand); + } + + @GET + @Path("/{id}") + public Map findOneRecord( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + @PathParam("id") String id, + @QueryParam("expand") List expand + ) { + requirePermission("data:" + modelName + ":view"); + return dataService.findOneRecord(projectId, modelName, id, expand); + } + + @POST + public Map createRecord( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + Map record + ) { + requirePermission("data:" + modelName + ":create"); + return dataService.createRecord(projectId, modelName, record); + } + + @POST + @Path("/batch") + public List> createRecords( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + List> records + ) { + validateBatchSize(records); + requirePermission("data:" + modelName + ":create"); + return dataService.createRecords(projectId, modelName, records); + } + + @PUT + @Path("/{id}") + public Map updateRecord( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + @PathParam("id") String id, + Map record + ) { + requirePermission("data:" + modelName + ":update"); + return dataService.updateRecord(projectId, modelName, id, record); + } + + @PATCH + @Path("/{id}") + public Map updateRecordIgnoreNull( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + @PathParam("id") String id, + Map record + ) { + requirePermission("data:" + modelName + ":update"); + return dataService.updateRecordIgnoreNull(projectId, modelName, id, record); + } + + @DELETE + @Path("/{id}") + public void deleteRecord( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + @PathParam("id") String id + ) { + requirePermission("data:" + modelName + ":delete"); + dataService.deleteRecord(projectId, modelName, id); + } + + @PUT + @Path("/batch") + public List> updateRecords( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + List> records + ) { + validateBatchSize(records); + requirePermission("data:" + modelName + ":update"); + return dataService.updateRecords(projectId, modelName, records); + } + + @DELETE + @Path("/batch") + public long deleteRecords( + @PathParam("projectId") String projectId, + @PathParam("modelName") String modelName, + List ids + ) { + validateBatchSize(ids); + requirePermission("data:" + modelName + ":delete"); + return dataService.deleteRecords(projectId, modelName, new ArrayList<>(ids)); + } + + private void requirePermission(String permission) { + Set permissions = sessionContext.getPermissions(); + if (permissions == null) { + return; + } + if (!PermissionHelper.hasPermission(permissions, permission)) { + throw new ForbiddenException("Permission denied: " + permission); + } + } + + private void validateBatchSize(List items) { + if (items == null || items.isEmpty()) { + throw new BadRequestException("请求体不能为空"); + } + if (items.size() > MAX_BATCH_SIZE) { + throw new BadRequestException("批量操作记录数不能超过 " + MAX_BATCH_SIZE); + } + } +} diff --git a/flexmodel-server/src/main/java/dev/flexmodel/open/OpenFunctionResource.java b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenFunctionResource.java new file mode 100644 index 0000000..94b76f9 --- /dev/null +++ b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenFunctionResource.java @@ -0,0 +1,58 @@ +package dev.flexmodel.open; + +import dev.flexmodel.functions.FunctionService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * Open API — 边缘函数调用(Java 代理)。 + *

+ * 路径前缀 {@code /open/{projectId}/functions}, + * 面向终端用户(IdP 用户 / open scope API Key)。 + * 仅暴露 invoke,不暴露 deploy/delete/list/get(admin only)。 + * + * @author cjbi + */ +@ApplicationScoped +@Path("/open/{projectId}/functions") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class OpenFunctionResource { + + @Inject + FunctionService functionService; + + /** + * 代理调用边缘函数(Java → Deno runtime)。 + */ + @POST + @Path("/{name}/invoke") + public Response invoke( + @PathParam("projectId") String projectId, + @PathParam("name") String name, + Object request + ) { + Response runtimeResponse = functionService.invoke(projectId, name, request); + + // Read body as raw bytes to bypass Jackson JSON parsing — edge functions + // may return arbitrary content types (text, binary, malformed JSON). + byte[] body = runtimeResponse.readEntity(byte[].class); + Response.ResponseBuilder builder = Response + .status(runtimeResponse.getStatus()) + .entity(body); + String contentType = runtimeResponse.getHeaderString("Content-Type"); + if (contentType != null) { + builder.header("Content-Type", contentType); + } + + String meta = runtimeResponse.getHeaderString("x-function-meta"); + if (meta != null) { + builder.header("X-Function-Meta", meta); + } + + return builder.build(); + } +} diff --git a/flexmodel-server/src/main/java/dev/flexmodel/open/OpenGraphQLResource.java b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenGraphQLResource.java new file mode 100644 index 0000000..dc96732 --- /dev/null +++ b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenGraphQLResource.java @@ -0,0 +1,45 @@ +package dev.flexmodel.open; + +import dev.flexmodel.api.GraphQLManager; +import graphql.ExecutionResult; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.Map; + +/** + * Open API — GraphQL 查询。 + *

+ * 路径前缀 {@code /open/{projectId}/graphql}, + * 面向终端用户(IdP 用户 / open scope API Key)。 + * + * @author cjbi + */ +@ApplicationScoped +@Path("/open/{projectId}/graphql") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class OpenGraphQLResource { + + @Inject + GraphQLManager graphQLManager; + + @POST + public ExecutionResult execute(@PathParam("projectId") String projectId, GraphQLRequest request) { + return graphQLManager.execute(projectId, request.operationName(), request.query(), request.variables()); + } + + public record GraphQLRequest( + @Schema(description = "操作名称") String operationName, + @Schema(description = "查询") String query, + @Schema(description = "变量") Map variables + ) { + } +} diff --git a/flexmodel-server/src/main/java/dev/flexmodel/open/OpenStorageResource.java b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenStorageResource.java new file mode 100644 index 0000000..fb7ab46 --- /dev/null +++ b/flexmodel-server/src/main/java/dev/flexmodel/open/OpenStorageResource.java @@ -0,0 +1,141 @@ +package dev.flexmodel.open; + +import dev.flexmodel.codegen.entity.Bucket; +import dev.flexmodel.storage.FileItem; +import dev.flexmodel.common.NotFoundException; +import dev.flexmodel.storage.BucketService; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; + +import java.io.InputStream; +import java.io.OutputStream; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Open API — 对象存储操作。 + *

+ * 路径前缀 {@code /open/{projectId}/buckets/{bucketName}/objects}, + * 面向终端用户(IdP 用户 / open scope API Key)。 + * 仅暴露对象读写操作,不暴露 bucket CRUD(admin only)。 + * + * @author cjbi + */ +@ApplicationScoped +@Path("/open/{projectId}/buckets/{bucketName}/objects") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class OpenStorageResource { + + private static final String OWNER_TYPE = "PROJECT"; + + @Inject + BucketService bucketService; + + @GET + public List listObjects( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @QueryParam("prefix") String prefix + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + return bucketService.listFiles(bucket, prefix != null ? prefix : ""); + } + + @GET + @Path("{path: .*}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public Response downloadObject( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @PathParam("path") String path + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + InputStream inputStream = bucketService.getInputStream(bucket, path); + String fileName = path.contains("/") ? path.substring(path.lastIndexOf('/') + 1) : path; + StreamingOutput stream = (OutputStream output) -> { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + output.write(buffer, 0, bytesRead); + } + inputStream.close(); + }; + return Response.ok(stream) + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .build(); + } + + @HEAD + @Path("{path: .*}") + public Response headObject( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @PathParam("path") String path + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + FileItem item = bucketService.getFile(bucket, path); + if (item == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + Response.ResponseBuilder rb = Response.ok(); + if (item.getSize() != null) { + rb.header("Content-Length", item.getSize()); + } + if (item.getLastModified() != null) { + rb.header("Last-Modified", DateTimeFormatter.RFC_1123_DATE_TIME + .withZone(ZoneOffset.UTC).format(item.getLastModified())); + } + return rb.build(); + } + + @GET + @Path("{path: .*}/metadata") + public FileItem getObjectMetadata( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @PathParam("path") String path + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + return bucketService.getFile(bucket, path); + } + + @PUT + @Path("{path: .*}") + @Consumes(MediaType.APPLICATION_OCTET_STREAM) + public Response uploadObject( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @PathParam("path") String path, + @QueryParam("folder") @DefaultValue("false") boolean folder, + @HeaderParam("Content-Length") long contentLength, + InputStream body + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + String objectPath = folder && !path.endsWith("/") ? path + "/" : path; + bucketService.uploadFile(bucket, objectPath, body, contentLength); + return Response.ok().build(); + } + + @DELETE + @Path("{path: .*}") + public Response deleteObject( + @PathParam("projectId") String projectId, + @PathParam("bucketName") String bucketName, + @PathParam("path") String path + ) { + Bucket bucket = resolveBucket(projectId, bucketName); + bucketService.deleteFile(bucket, path); + return Response.noContent().build(); + } + + private Bucket resolveBucket(String projectId, String bucketName) { + return bucketService.getBucket(OWNER_TYPE, projectId, bucketName) + .orElseThrow(() -> new NotFoundException("Bucket not found: " + bucketName)); + } +} diff --git a/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java b/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java index 45dd70a..0e7c2ce 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java @@ -66,8 +66,8 @@ public Map getProfile() { "settings", settingsService.getSettings(), "apiRootPath", config.apiRootPath(), "storageProvider", storageProvider.getProviderInfo(), - "projectBaseDomain", config.projectBaseDomain(), - "routingMode", config.projectRoutingMode(), + "projectBaseDomain", config.projectBaseDomain().orElse(""), + "routingMode", config.isSubdomainRouting() ? "subdomain" : "path", "edgeUrlTemplate", config.edgeUrlTemplate(), "pagesUrlTemplate", config.pagesUrlTemplate() ); diff --git a/flexmodel-server/src/main/resources/application.properties b/flexmodel-server/src/main/resources/application.properties index 91f9db6..1aa85dd 100644 --- a/flexmodel-server/src/main/resources/application.properties +++ b/flexmodel-server/src/main/resources/application.properties @@ -52,13 +52,12 @@ flexmodel.datasource.dev_test.url=jdbc:sqlite:file:database/sqlite_dev_test.db flexmodel.jwt.secret=storewebkey flexmodel.jwt.access-token-lifetime=7d flexmodel.jwt.refresh-token-lifetime=30d -flexmodel.project-base-domain=localhost -# Routing mode: "path" for local development (path-based), "subdomain" for production. -# In "path" mode, pages are served at /pages/{projectId}, -# functions are invoked at /functions/{projectId}/{name}. -# In "subdomain" mode, pages are served at {projectId}.{projectBaseDomain}, -# functions are invoked at {projectId}.{projectBaseDomain}/functions/{name}. -flexmodel.project-routing-mode=path +# Project base domain — leave unset (default empty) for path mode (/pages/{projectId}), +# set to a real domain for subdomain mode ({projectId}.{domain} serves pages). +# flexmodel.project-base-domain=flexmodel.example.com +# Routing mode is auto-inferred from flexmodel.project-base-domain: +# real domain (non-localhost) -> subdomain mode ({projectId}.{domain} serves pages) +# localhost / unset -> path mode (/pages/{projectId}) # Storage Provider Configuration # flexmodel.storage.type=local # flexmodel.storage.local-path=./storage diff --git a/flexmodel-server/src/main/resources/platform.fml b/flexmodel-server/src/main/resources/platform.fml index 5c555a1..7580641 100644 --- a/flexmodel-server/src/main/resources/platform.fml +++ b/flexmodel-server/src/main/resources/platform.fml @@ -36,7 +36,6 @@ model f_auth_api_key { name : String @length("255") @comment("Key 名称"), key_hash : String @length("64") @comment("SHA-256 哈希"), key_prefix : String @length("16") @comment("前缀(fm_ak_xxx)"), - key_type : String @length("50") @comment("类型: anon/service/custom"), project_ids? : String @length("1000") @comment("可访问的项目ID列表,逗号分隔,空表示全部"), read_only : Boolean @default("false") @comment("是否只读"), expires_at? : DateTime @comment("过期时间"), diff --git a/flexmodel-server/src/test/java/dev/flexmodel/pages/PageAliasManagerTest.java b/flexmodel-server/src/test/java/dev/flexmodel/pages/PageAliasManagerTest.java index f922f75..da5dc0b 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/pages/PageAliasManagerTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/pages/PageAliasManagerTest.java @@ -210,10 +210,9 @@ public PagesConfig pages() { public String projectUrlTemplate() { return ""; } @Override - public String projectBaseDomain() { return "localhost"; } - - @Override - public String projectRoutingMode() { return "path"; } + public java.util.Optional projectBaseDomain() { + return java.util.Optional.empty(); + } @Override public java.util.Map datasources() { return java.util.Map.of(); } diff --git a/flexmodel-server/src/test/java/dev/flexmodel/pages/PageDeployerTest.java b/flexmodel-server/src/test/java/dev/flexmodel/pages/PageDeployerTest.java index 17f221f..3a57622 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/pages/PageDeployerTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/pages/PageDeployerTest.java @@ -239,10 +239,9 @@ public PagesConfig pages() { public String projectUrlTemplate() { return ""; } @Override - public String projectBaseDomain() { return "localhost"; } - - @Override - public String projectRoutingMode() { return "path"; } + public java.util.Optional projectBaseDomain() { + return java.util.Optional.empty(); + } @Override public java.util.Map datasources() { return java.util.Map.of(); } diff --git a/flexmodel-server/src/test/java/dev/flexmodel/rest/ApiKeyResourceTest.java b/flexmodel-server/src/test/java/dev/flexmodel/rest/ApiKeyResourceTest.java index 12b6038..4262358 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/rest/ApiKeyResourceTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/rest/ApiKeyResourceTest.java @@ -49,7 +49,6 @@ void testCreateApiKey() { .body(""" { "name": "E2E测试API Key", - "keyType": "user", "projectIds": "dev_test", "readOnly": false } @@ -85,7 +84,6 @@ void testCreateReadOnlyApiKey() { .body(""" { "name": "E2E只读Key", - "keyType": "user", "projectIds": "dev_test", "readOnly": true } @@ -120,7 +118,6 @@ void testRegenerateApiKey() { .body(""" { "name": "E2E重新生成Key", - "keyType": "user", "projectIds": "dev_test", "readOnly": false } @@ -168,7 +165,6 @@ void testDeleteApiKey() { .body(""" { "name": "E2E待删除Key", - "keyType": "user", "projectIds": "dev_test", "readOnly": false } @@ -211,7 +207,6 @@ void testCompleteApiKeyCrudFlow() { .body(""" { "name": "E2E CRUD Key", - "keyType": "user", "projectIds": "dev_test", "readOnly": false } diff --git a/flexmodel-server/src/test/java/dev/flexmodel/rest/GlobalResourceTest.java b/flexmodel-server/src/test/java/dev/flexmodel/rest/GlobalResourceTest.java index 4e96952..39d598f 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/rest/GlobalResourceTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/rest/GlobalResourceTest.java @@ -121,7 +121,7 @@ void testProfileContainsRoutingMode() { } /** - * 测试 path 模式下 edgeUrlTemplate 为相对路径 + * 测试 path 模式(localhost)下 edgeUrlTemplate 为经 Java 代理的相对路径 */ @Test void testPathModeEdgeUrlTemplate() { @@ -130,7 +130,7 @@ void testPathModeEdgeUrlTemplate() { .get(BASE_PATH + "/profile") .then() .statusCode(200) - .body("edgeUrlTemplate", equalTo("/functions/{{projectId}}/{{name}}")); + .body("edgeUrlTemplate", equalTo("/api/open/{{projectId}}/functions/{{name}}/invoke")); } /** diff --git a/flexmodel-server/src/test/java/dev/flexmodel/rest/PageResourceTest.java b/flexmodel-server/src/test/java/dev/flexmodel/rest/PageResourceTest.java index c1c59ef..2e7da15 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/rest/PageResourceTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/rest/PageResourceTest.java @@ -55,8 +55,7 @@ void testUpdatePageSite_autoCreate() { .put(BASE_PATH) .then() .statusCode(200) - .body("status", equalTo("READY")) - .body("customDomains", hasItems("example.com", "www.example.com")); + .body("status", equalTo("READY")); } /** @@ -87,7 +86,7 @@ void testGetPageSite_afterUpdate() { .then() .statusCode(200) .body("status", notNullValue()) - .body("customDomains", notNullValue()); + .body("status", equalTo("READY")); } /** @@ -151,8 +150,7 @@ void testCompletePageCrudFlow() { .when() .put(BASE_PATH) .then() - .statusCode(200) - .body("customDomains", hasItem("crud-test.example.com")); + .statusCode(200); // 2. 获取站点配置确认 given() @@ -161,7 +159,7 @@ void testCompletePageCrudFlow() { .get(BASE_PATH) .then() .statusCode(200) - .body("customDomains", hasItem("crud-test.example.com")); + .body("status", equalTo("READY")); // 3. 再次更新(覆盖 customDomains) given() @@ -177,6 +175,6 @@ void testCompletePageCrudFlow() { .put(BASE_PATH) .then() .statusCode(200) - .body("customDomains", hasItem("updated.example.com")); + .body("status", equalTo("READY")); } } diff --git a/flexmodel-server/src/test/java/dev/flexmodel/rest/PermissionFilterTest.java b/flexmodel-server/src/test/java/dev/flexmodel/rest/PermissionFilterTest.java index 370f08a..3d6faa3 100644 --- a/flexmodel-server/src/test/java/dev/flexmodel/rest/PermissionFilterTest.java +++ b/flexmodel-server/src/test/java/dev/flexmodel/rest/PermissionFilterTest.java @@ -249,20 +249,6 @@ void graphqlMissingPermDeny() { .statusCode(403); } - @Test - void functionViewDenyInvoke() { - given() - .header("Authorization", testTokenHelper.getAuthorizationHeader()) - .header("X-Test-Permissions", "function:view") - .contentType(ContentType.JSON) - .body("{}") - .when() - .post(Resources.ROOT_PATH + "/projects/dev_test/functions/FakeFunc/invoke") - .then() - .statusCode(403); - } - - @Test void emptyPermissionsDeny() { given() .header("Authorization", testTokenHelper.getAuthorizationHeader()) diff --git a/flexmodel-ui b/flexmodel-ui index a6ae92e..8d0b601 160000 --- a/flexmodel-ui +++ b/flexmodel-ui @@ -1 +1 @@ -Subproject commit a6ae92ec53453dd5945b8a8597c6ea7f5b930ed6 +Subproject commit 8d0b6013dd2b421e2ab6eb4de7ab9a2e024afc0e