From 5e103af7417ce684eeb5b33b81eac65697634d9d Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 13:09:47 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(api):=20=E5=AE=9E=E7=8E=B0=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2/=E6=95=B0=E6=8D=AE=E9=9D=A2=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E7=9A=84=E5=8F=8C=E5=B1=82API=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入open/admin双surface认证体系,open surface支持open scope API Key和匿名访问 - 新增OpenDataResource和OpenFunctionResource提供面向终端用户的开放API端点 - 修改Nginx配置实现子域名路由到页面和开放API,主域名路由到管理API - 更新API Key生成逻辑,区分admin/open作用域并调整前缀格式 - 重构认证过滤器实现基于路径的API surface隔离和权限校验 - 调整边缘函数调用路径从/functions/{projectId}/{name}改为/open/{projectId}/functions/{name} - 更新相关测试用例和依赖配置以适配新的API架构设计 --- deploy/docker-compose/.env | 4 +- .../nginx/conf.d/default.conf.template | 93 +++------- flexmodel-functions-runtime/deno.json | 2 +- flexmodel-functions-runtime/deno.lock | 8 +- .../src/router/functions.ts | 17 +- .../src/runner/registry.ts | 2 +- .../src/runner/worker_test.ts | 3 +- flexmodel-sdks | 2 +- .../flexmodel/auth/dto/ApiKeyResponse.java | 4 +- .../auth/dto/CreateApiKeyRequest.java | 2 +- .../auth/service/ApiKeyGenerator.java | 8 +- .../flexmodel/auth/service/ApiKeyService.java | 8 +- .../dev/flexmodel/common/FlexmodelConfig.java | 4 +- .../common/config/web/filter/AuthFilter.java | 165 ++++++++++++++---- .../flexmodel/functions/FunctionResource.java | 28 +-- .../flexmodel/functions/FunctionService.java | 61 ------- .../functions/dto/InvokeTokenResponse.java | 25 --- .../dev/flexmodel/open/OpenDataResource.java | 165 ++++++++++++++++++ .../flexmodel/open/OpenFunctionResource.java | 58 ++++++ .../flexmodel/open/OpenGraphQLResource.java | 45 +++++ .../flexmodel/open/OpenStorageResource.java | 141 +++++++++++++++ .../projectauth/EdgeValidateResource.java | 2 + .../src/main/resources/application.properties | 2 +- .../src/main/resources/platform.fml | 2 +- .../flexmodel/rest/ApiKeyResourceTest.java | 10 +- flexmodel-ui | 2 +- 26 files changed, 616 insertions(+), 247 deletions(-) delete mode 100644 flexmodel-server/src/main/java/dev/flexmodel/functions/dto/InvokeTokenResponse.java create mode 100644 flexmodel-server/src/main/java/dev/flexmodel/open/OpenDataResource.java create mode 100644 flexmodel-server/src/main/java/dev/flexmodel/open/OpenFunctionResource.java create mode 100644 flexmodel-server/src/main/java/dev/flexmodel/open/OpenGraphQLResource.java create mode 100644 flexmodel-server/src/main/java/dev/flexmodel/open/OpenStorageResource.java diff --git a/deploy/docker-compose/.env b/deploy/docker-compose/.env index cb903bf..bd6eab0 100644 --- a/deploy/docker-compose/.env +++ b/deploy/docker-compose/.env @@ -16,10 +16,10 @@ 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 +FLEXMODEL_PROJECT_BASE_DOMAIN=flexmodel.wetech.tech # 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_ROUTING_MODE=subdomain \ No newline at end of file diff --git a/deploy/docker-compose/nginx/conf.d/default.conf.template b/deploy/docker-compose/nginx/conf.d/default.conf.template index df0bf28..d5b9329 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,51 +31,6 @@ 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/(?[^/]+)(/(?[^/]+))?/(?.*)$ { - 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; - } - # UI reverse proxy location / { proxy_pass http://flexmodel-ui:80; @@ -82,42 +48,35 @@ 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) 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..a59ecc9 100644 --- a/flexmodel-functions-runtime/deno.lock +++ b/flexmodel-functions-runtime/deno.lock @@ -11,7 +11,7 @@ "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:@flexmodel/sdk@0.0.7": "0.0.7", "npm:jose@^5.9.0": "5.10.0", "npm:jsonwebtoken@*": "9.0.3", "npm:openai@*": "6.46.0" @@ -60,10 +60,6 @@ } }, "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" @@ -163,7 +159,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..fc687fc 100644 --- a/flexmodel-functions-runtime/src/router/functions.ts +++ b/flexmodel-functions-runtime/src/router/functions.ts @@ -7,8 +7,8 @@ // POST /functions/:projectId/:name/invoke — invoke (Java-proxied) // // External route (Frontend → Deno, auth required): -// POST /functions/:projectId/:name — invoke (direct, with invoke-token/API-Key) -// OPTIONS /functions/:projectId/:name — CORS preflight +// POST /open/:projectId/functions/:name — invoke (direct, with invoke-token/API-Key) +// OPTIONS /open/:projectId/functions/:name — CORS preflight // ============================================================ import {Hono} from "hono"; @@ -129,19 +129,20 @@ router.post("/functions/:projectId/:name/invoke", async (c) => { // ============================================================ // External route (Frontend → Deno, auth required) -// POST /functions/:projectId/:name — direct invoke with invoke-token/API-Key -// No /invoke suffix — this is how we distinguish external vs internal calls. +// POST /open/:projectId/functions/:name — direct invoke with invoke-token/API-Key +// Separate path prefix (/open/) distinguishes external vs internal calls. // ============================================================ -// ---- OPTIONS /functions/:projectId/:name — CORS preflight ---- -router.options("/functions/:projectId/:name", edgeCorsMiddleware); +// ---- OPTIONS /open/:projectId/functions/:name — CORS preflight ---- +router.options("/open/:projectId/functions/:name", edgeCorsMiddleware); -// ---- POST /functions/:projectId/:name ---- +// ---- POST /open/:projectId/functions/:name ---- +// Open API external invoke: POST /open/:projectId/functions/:name // External invoke: frontend calls this directly with invoke-token or API Key. // Auth: invoke-token JWT (Bearer) or API Key (fm_ak_ prefix) // Auto-deploy: if function not registered, fetch from Java and deploy router.post( - "/functions/:projectId/:name", + "/open/:projectId/functions/:name", edgeCorsMiddleware, edgeAuthMiddleware, autoDeployMiddleware, diff --git a/flexmodel-functions-runtime/src/runner/registry.ts b/flexmodel-functions-runtime/src/runner/registry.ts index 30739e7..e05bb7c 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": "file:///C:/Users/cjbi/git-repository/flexmodel/flexmodel-sdks/typescript/dist/index.js", }, }, null, 2); } diff --git a/flexmodel-functions-runtime/src/runner/worker_test.ts b/flexmodel-functions-runtime/src/runner/worker_test.ts index afa4669..d50afae 100644 --- a/flexmodel-functions-runtime/src/runner/worker_test.ts +++ b/flexmodel-functions-runtime/src/runner/worker_test.ts @@ -188,7 +188,8 @@ Deno.test("invokeFunction runs user code that calls SDK directly", async () => { 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")) { + // 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" } }, 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..b733719 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,7 @@ public class ApiKeyResponse { private String id; private String name; private String keyPrefix; - private String keyType; + private String scope; private String projectIds; private boolean readOnly; private LocalDateTime expiresAt; @@ -27,7 +27,7 @@ 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.setScope(entity.getScope()); 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..481f4c0 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,7 @@ public record CreateApiKeyRequest( String name, - String keyType, + String scope, 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..841135d 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,7 @@ /** * API Key 生成工具。 - * 生成格式:fm_ak_{type}_{random40chars} + * 生成格式:fm_ak_{scope}_{random40chars} * 存储 SHA-256 哈希,不存原文。 * *

{@link SecureRandom} 实例为方法内局部变量, @@ -25,17 +25,17 @@ public record GeneratedKey(String plainText, String hash, String prefix) { /** * 生成一个新的 API Key。 * - * @param keyType anon / service / custom + * @param scope admin / open * @return 包含明文、SHA-256 哈希和前缀的 GeneratedKey */ - public static GeneratedKey generate(String keyType) { + public static GeneratedKey generate(String scope) { 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_" + scope + "_" + 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..87cb913 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,14 @@ 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); + String scope = request.scope() != null ? request.scope() : "open"; + ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(scope); AuthApiKey entity = new AuthApiKey(); entity.setName(request.name()); entity.setKeyHash(generated.hash()); entity.setKeyPrefix(generated.prefix()); - entity.setKeyType(keyType); + entity.setScope(scope); entity.setProjectIds(request.projectIds()); entity.setReadOnly(request.readOnly()); @@ -54,7 +54,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.getScope()); 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..162b40d 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java @@ -41,14 +41,14 @@ public interface FlexmodelConfig extends Serializable { /** * Derive the edge function URL based on routing mode and project base domain. - * - path mode: /functions/{{projectId}}/{{name}} + * - path mode: /open/{{projectId}}/functions/{{name}} * - subdomain mode: https://{{projectId}}.{projectBaseDomain}/functions/{{name}} */ default String edgeUrlTemplate() { if ("subdomain".equals(projectRoutingMode())) { return "https://{{projectId}}." + projectBaseDomain() + "/functions/{{name}}"; } - return "/functions/{{projectId}}/{{name}}"; + return "/open/{{projectId}}/functions/{{name}}"; } /** 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..8e7e811 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,38 @@ private boolean trySystemJwt(String token, ContainerRequestContext requestContex } /** - * 尝试 API Key 验证(fm_ak_ 前缀)。 + * 尝试 admin scope API Key 验证。 */ - 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 (!"admin".equals(apiKey.getScope())) { + return false; // open scope key cannot access admin surface + } + if (!isProjectAllowed(apiKey, projectId)) { + return false; + } + fillSessionContextForApiKey(requestContext, apiKey, projectId); + return true; + } + + // ============================================================ + // OPEN surface auth methods + // ============================================================ + + /** + * 尝试 open scope API Key 验证。 + */ + private boolean tryOpenApiKey(String token, ContainerRequestContext requestContext, String projectId) { + AuthApiKey apiKey = apiKeyService.validate(token); + if (apiKey == null) { + return false; + } + if (!"open".equals(apiKey.getScope())) { + return false; // admin scope key cannot access open surface + } if (!isProjectAllowed(apiKey, projectId)) { return false; } @@ -129,6 +199,10 @@ private boolean tryApiKey(String token, ContainerRequestContext requestContext, return true; } + // ============================================================ + // Shared auth methods + // ============================================================ + /** * 检查系统级 API Key 是否允许访问指定项目。 */ @@ -149,6 +223,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 +280,10 @@ private AuthContext buildAuthContext(String projectId, String token, ContainerRe return ctx; } + // ============================================================ + // Session context fillers + // ============================================================ + /** * 系统 JWT 认证 -> 填充上下文(管理后台用户)。 */ @@ -247,12 +334,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(); @@ -274,7 +373,6 @@ private boolean isAnonymousPublicBucketRead(ContainerRequestContext requestConte .filter(BucketVisibility.PUBLIC::equals) .isPresent(); } catch (Exception e) { - // Bucket / 项目不存在或解析失败时按非公开处理,回退到标准认证流程 return false; } } @@ -283,7 +381,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..65706ec 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java @@ -64,10 +64,17 @@ public Response invoke(@PathParam("projectId") String projectId, Object request) { Response runtimeResponse = functionService.invoke(projectId, name, request); - // Pass through function result directly as HTTP response + // Pass through function result directly as HTTP response. + // 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(runtimeResponse.readEntity(Object.class)); + .entity(body); + String contentType = runtimeResponse.getHeaderString("Content-Type"); + if (contentType != null) { + builder.header("Content-Type", contentType); + } // Forward x-function-meta header for observability String meta = runtimeResponse.getHeaderString("x-function-meta"); @@ -77,21 +84,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/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/projectauth/EdgeValidateResource.java b/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java index 789b8ba..340b858 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java @@ -126,6 +126,8 @@ private EdgeValidateResponse tryInvokeToken(String token, String projectId) { private EdgeValidateResponse tryApiKey(String token, String projectId, String functionName) { AuthApiKey apiKey = apiKeyService.validate(token); if (apiKey == null) return null; + // Only open scope keys can directly invoke functions via Deno + if (!"open".equals(apiKey.getScope())) return null; String authToken = internalTokenService.signToken(projectId); String invokeId = "ak-" + UUID.randomUUID().toString().substring(0, 8); diff --git a/flexmodel-server/src/main/resources/application.properties b/flexmodel-server/src/main/resources/application.properties index 91f9db6..2079a01 100644 --- a/flexmodel-server/src/main/resources/application.properties +++ b/flexmodel-server/src/main/resources/application.properties @@ -55,7 +55,7 @@ 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}. +# edge functions are invoked directly at /open/{projectId}/functions/{name}. # In "subdomain" mode, pages are served at {projectId}.{projectBaseDomain}, # functions are invoked at {projectId}.{projectBaseDomain}/functions/{name}. flexmodel.project-routing-mode=path diff --git a/flexmodel-server/src/main/resources/platform.fml b/flexmodel-server/src/main/resources/platform.fml index 5c555a1..4178f4f 100644 --- a/flexmodel-server/src/main/resources/platform.fml +++ b/flexmodel-server/src/main/resources/platform.fml @@ -36,7 +36,7 @@ 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"), + scope : String @length("20") @default("open") @comment("权限范围: admin/open"), 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/rest/ApiKeyResourceTest.java b/flexmodel-server/src/test/java/dev/flexmodel/rest/ApiKeyResourceTest.java index 12b6038..63bd47b 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,7 @@ void testCreateApiKey() { .body(""" { "name": "E2E测试API Key", - "keyType": "user", + "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -85,7 +85,7 @@ void testCreateReadOnlyApiKey() { .body(""" { "name": "E2E只读Key", - "keyType": "user", + "scope": "open", "projectIds": "dev_test", "readOnly": true } @@ -120,7 +120,7 @@ void testRegenerateApiKey() { .body(""" { "name": "E2E重新生成Key", - "keyType": "user", + "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -168,7 +168,7 @@ void testDeleteApiKey() { .body(""" { "name": "E2E待删除Key", - "keyType": "user", + "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -211,7 +211,7 @@ void testCompleteApiKeyCrudFlow() { .body(""" { "name": "E2E CRUD Key", - "keyType": "user", + "scope": "open", "projectIds": "dev_test", "readOnly": false } diff --git a/flexmodel-ui b/flexmodel-ui index a6ae92e..7c22fd9 160000 --- a/flexmodel-ui +++ b/flexmodel-ui @@ -1 +1 @@ -Subproject commit a6ae92ec53453dd5945b8a8597c6ea7f5b930ed6 +Subproject commit 7c22fd9f8622770ee67b404976e0de71e6d42271 From c2555539bbba73835a444ac7d70a27432f4b5469 Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 15:20:54 +0800 Subject: [PATCH 02/10] =?UTF-8?q?refactor(functions):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E8=BF=90=E8=A1=8C=E6=97=B6=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=92=8C=E8=AF=B7=E6=B1=82=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除外部调用路径中的 /open 前缀,统一使用 /functions/:projectId/:name - 修改内部 Java 代理调用路径为 /functions/:projectId/:name/invoke - 简化请求体处理逻辑,移除嵌套的 input 字段包装 - 更新 SDK 导入方式从本地文件路径改为 npm 包引用 - 调整测试代码的缩进格式 - 优化 Java 服务端响应处理,直接透传函数执行结果 - 添加 WILDCARD 消费类型注解支持任意内容类型 - 排除 accept-encoding 头部避免内部传输压缩导致的内容类型匹配失败 --- .../src/router/functions.ts | 135 ++++--- .../src/runner/registry.ts | 2 +- .../src/runner/worker_test.ts | 377 +++++++++--------- .../flexmodel/functions/FunctionResource.java | 11 +- .../functions/FunctionRuntimeClient.java | 1 + .../FunctionRuntimeClientHeadersFactory.java | 6 +- 6 files changed, 264 insertions(+), 268 deletions(-) diff --git a/flexmodel-functions-runtime/src/router/functions.ts b/flexmodel-functions-runtime/src/router/functions.ts index fc687fc..a539598 100644 --- a/flexmodel-functions-runtime/src/router/functions.ts +++ b/flexmodel-functions-runtime/src/router/functions.ts @@ -7,8 +7,8 @@ // POST /functions/:projectId/:name/invoke — invoke (Java-proxied) // // External route (Frontend → Deno, auth required): -// POST /open/:projectId/functions/:name — invoke (direct, with invoke-token/API-Key) -// OPTIONS /open/:projectId/functions/:name — CORS preflight +// POST /functions/:projectId/:name — invoke (direct, with invoke-token/API-Key) +// OPTIONS /functions/:projectId/:name — CORS preflight // ============================================================ import {Hono} from "hono"; @@ -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,51 +98,50 @@ 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)}, + ); + } }); // ============================================================ // External route (Frontend → Deno, auth required) -// POST /open/:projectId/functions/:name — direct invoke with invoke-token/API-Key -// Separate path prefix (/open/) distinguishes external vs internal calls. +// POST /functions/:projectId/:name — direct invoke with invoke-token/API-Key +// No /invoke suffix — this is how we distinguish external vs internal calls. // ============================================================ -// ---- OPTIONS /open/:projectId/functions/:name — CORS preflight ---- -router.options("/open/:projectId/functions/:name", edgeCorsMiddleware); +// ---- OPTIONS /functions/:projectId/:name — CORS preflight ---- +router.options("/functions/:projectId/:name", edgeCorsMiddleware); -// ---- POST /open/:projectId/functions/:name ---- -// Open API external invoke: POST /open/:projectId/functions/:name +// ---- POST /functions/:projectId/:name ---- // External invoke: frontend calls this directly with invoke-token or API Key. // Auth: invoke-token JWT (Bearer) or API Key (fm_ak_ prefix) // Auto-deploy: if function not registered, fetch from Java and deploy router.post( - "/open/:projectId/functions/:name", + "/functions/:projectId/:name", edgeCorsMiddleware, edgeAuthMiddleware, autoDeployMiddleware, diff --git a/flexmodel-functions-runtime/src/runner/registry.ts b/flexmodel-functions-runtime/src/runner/registry.ts index e05bb7c..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": "file:///C:/Users/cjbi/git-repository/flexmodel/flexmodel-sdks/typescript/dist/index.js", + "@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 d50afae..2e95833 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,142 +72,141 @@ 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 - // 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", - ` + 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", + ` import { flexmodelClient } from "@flexmodel/sdk"; export default async (req: Request) => { @@ -216,30 +215,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 { @@ -253,25 +252,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-server/src/main/java/dev/flexmodel/functions/FunctionResource.java b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java index 65706ec..842b797 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/functions/FunctionResource.java @@ -64,17 +64,10 @@ public Response invoke(@PathParam("projectId") String projectId, Object request) { Response runtimeResponse = functionService.invoke(projectId, name, request); - // Pass through function result directly as HTTP response. - // 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); + // Pass through function result directly as HTTP response Response.ResponseBuilder builder = Response .status(runtimeResponse.getStatus()) - .entity(body); - String contentType = runtimeResponse.getHeaderString("Content-Type"); - if (contentType != null) { - builder.header("Content-Type", contentType); - } + .entity(runtimeResponse.readEntity(Object.class)); // Forward x-function-meta header for observability String meta = runtimeResponse.getHeaderString("x-function-meta"); 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 From abba57bee17054538e4810d6d0026d6ca201ad87 Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 16:07:59 +0800 Subject: [PATCH 03/10] =?UTF-8?q?chore(deps):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=8C=85=E7=89=88=E6=9C=AC=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96UI=E7=BB=84=E4=BB=B6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 deno.lock 中 @hono/hono 版本从 4.12.24 到 4.12.27 - 移除 deno.lock 中 jsonwebtoken 和 openai 相关依赖 - 在数据建模页面添加刷新功能按钮 - 调整函数页面中搜索框和按钮的位置布局 - 移除项目概览面板中的函数入口URL显示 - 为文件浏览器组件添加上传文件列表状态管理 - 实现上传模态框关闭时清空文件列表功能 --- flexmodel-functions-runtime/deno.lock | 101 ++------------------------ flexmodel-ui | 2 +- 2 files changed, 8 insertions(+), 95 deletions(-) diff --git a/flexmodel-functions-runtime/deno.lock b/flexmodel-functions-runtime/deno.lock index a59ecc9..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", @@ -12,13 +12,11 @@ "jsr:@std/path@^1.1.5": "1.1.5", "jsr:@std/testing@1": "1.0.19", "npm:@flexmodel/sdk@0.0.7": "0.0.7", - "npm:jose@^5.9.0": "5.10.0", - "npm:jsonwebtoken@*": "9.0.3", - "npm:openai@*": "6.46.0" + "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,98 +58,13 @@ } }, "npm": { - "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": { diff --git a/flexmodel-ui b/flexmodel-ui index 7c22fd9..44f6e9f 160000 --- a/flexmodel-ui +++ b/flexmodel-ui @@ -1 +1 @@ -Subproject commit 7c22fd9f8622770ee67b404976e0de71e6d42271 +Subproject commit 44f6e9f6446470b416ad2108d7af95cb1ae7a72b From f039f17bdf4b5b3366b7cbbde3e8e2b5c66af62e Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 17:23:53 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E8=BF=87=E6=BB=A4=E5=99=A8=E8=B7=AF=E5=BE=84=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改正则表达式以支持 open 路径前缀 - 保持对 projects 路径的向后兼容性 - 修复路径匹配逻辑确保正确验证对象访问权限 --- .../java/dev/flexmodel/common/config/web/filter/AuthFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8e7e811..ba3b799 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 @@ -359,7 +359,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"); From ceeb6acff1342c1796bd69f73d2355465837e3c5 Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 17:34:34 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(worker):=20=E4=BF=AE=E5=A4=8DSDK?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=AE=A2=E6=88=B7=E7=AB=AFAPI=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为SDK开放客户端调用添加/api/open路径支持 - 更新mock服务器路径匹配逻辑以正确处理开放API端点 --- flexmodel-functions-runtime/src/runner/worker_test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmodel-functions-runtime/src/runner/worker_test.ts b/flexmodel-functions-runtime/src/runner/worker_test.ts index 2e95833..fe72cc8 100644 --- a/flexmodel-functions-runtime/src/runner/worker_test.ts +++ b/flexmodel-functions-runtime/src/runner/worker_test.ts @@ -188,7 +188,8 @@ Deno.test("invokeFunction runs user code that calls SDK directly", async () => { 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")) { + // 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"}}, From 7039f1f2f4f9ee16278ba2050b1d4bc00e0c32ba Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Thu, 13 Aug 2026 11:40:44 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(nginx):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=B7=AF=E7=94=B1=E6=94=AF=E6=8C=81=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=92=8C=E5=AD=90=E5=9F=9F=E5=90=8D=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 pages 路径模式配置,当 project-base-domain 为空时自动启用 - 移除 nginx 配置中的 index 指令,优化静态资源加载 - 统一静态资源缓存策略,使用 $pages_base 变量替代 $file_base - 删除自定义 MIME 类型定义,使用 nginx 默认配置 - 移除 404 错误页面配置,使用默认行为 refactor(config): 简化项目路由配置逻辑 - 移除 FLEXMODEL_PROJECT_ROUTING_MODE 环境变量配置 - 通过 FLEXMODEL_PROJECT_BASE_DOMAIN 是否为空自动推断路由模式 - 更新应用配置类中的路由模式判断逻辑 - 修改测试配置以适应新的路由模式推断 feat(core): 实现实体定义数据库迁移功能 - 添加 migrateEntity 方法支持增量字段更新 - 在 Session 中使用新迁移逻辑替换原有创建逻辑 - 在 SchemaFactory 中预加载现有模型定义用于迁移对比 - 支持 failsafe 模式下的表结构补全机制 --- deploy/docker-compose/.env | 8 +--- deploy/docker-compose/docker-compose.yml | 1 - .../nginx/conf.d/default.conf.template | 27 ++++--------- .../dev/flexmodel/service/SchemaService.java | 32 +++++++++++++++ .../java/dev/flexmodel/session/Session.java | 17 +------- .../dev/flexmodel/session/SessionFactory.java | 6 ++- .../dev/flexmodel/common/FlexmodelConfig.java | 39 ++++++++++--------- .../flexmodel/settings/GlobalResource.java | 2 +- .../src/main/resources/application.properties | 13 +++---- .../flexmodel/pages/PageAliasManagerTest.java | 7 ++-- .../dev/flexmodel/pages/PageDeployerTest.java | 7 ++-- .../flexmodel/rest/GlobalResourceTest.java | 4 +- 12 files changed, 82 insertions(+), 81 deletions(-) diff --git a/deploy/docker-compose/.env b/deploy/docker-compose/.env index bd6eab0..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=flexmodel.wetech.tech -# 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=subdomain \ 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 d5b9329..eed8222 100644 --- a/deploy/docker-compose/nginx/conf.d/default.conf.template +++ b/deploy/docker-compose/nginx/conf.d/default.conf.template @@ -31,6 +31,13 @@ server { add_header Cache-Control no-cache; } + # Pages (path mode) — /pages/{projectId}/... → /data/pages/{projectId}/production/... + # 当 project-base-domain 为空时自动走 path 模式。 + location ~ ^/pages/(?[^/]+)(?.*)$ { + root /data/pages; + try_files /$pproj/production$prest /$pproj/production/index.html =404; + } + # UI reverse proxy location / { proxy_pass http://flexmodel-ui:80; @@ -60,7 +67,6 @@ server { ~^(?[^.]+)\.${FLEXMODEL_PROJECT_BASE_DOMAIN_ESCAPED}$; root /data/pages; - index index.html; client_max_body_size 1024m; # Open API → Java (SDK sends /api/open/{projectId}/..., no rewrite needed) @@ -81,8 +87,7 @@ server { # 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"; } @@ -95,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-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java b/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java index 162b40d..a39e1cf 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/common/FlexmodelConfig.java @@ -25,39 +25,40 @@ public interface FlexmodelConfig extends Serializable { * Project base domain, used for CORS and URL construction in subdomain routing mode. */ @WithName("project-base-domain") - @WithDefault("localhost") String 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() { + String domain = projectBaseDomain(); + return domain != null && !domain.isBlank(); + } /** - * Derive the edge function URL based on routing mode and project base domain. - * - path mode: /open/{{projectId}}/functions/{{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() + + "/api/open/{{projectId}}/functions/{{name}}/invoke"; } - return "/open/{{projectId}}/functions/{{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())) { + if (isSubdomainRouting()) { return "https://{{projectId}}." + projectBaseDomain(); } return "/pages/{{projectId}}"; 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..3490d98 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/settings/GlobalResource.java @@ -67,7 +67,7 @@ public Map getProfile() { "apiRootPath", config.apiRootPath(), "storageProvider", storageProvider.getProviderInfo(), "projectBaseDomain", config.projectBaseDomain(), - "routingMode", config.projectRoutingMode(), + "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 2079a01..237e2ec0 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}, -# edge functions are invoked directly at /open/{projectId}/functions/{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 empty for path mode (/pages/{projectId}), +# set to a real domain for subdomain mode ({projectId}.{domain} serves pages). +flexmodel.project-base-domain= +# 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/test/java/dev/flexmodel/pages/PageAliasManagerTest.java b/flexmodel-server/src/test/java/dev/flexmodel/pages/PageAliasManagerTest.java index f922f75..1092105 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 String projectBaseDomain() { + return ""; + } @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..5832dfa 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 String projectBaseDomain() { + return ""; + } @Override public java.util.Map datasources() { return java.util.Map.of(); } 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")); } /** From a248ef0ed983aca69a156b5df16e5e2073aa306f Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Thu, 13 Aug 2026 15:44:36 +0800 Subject: [PATCH 07/10] =?UTF-8?q?refactor(auth):=20=E7=A7=BB=E9=99=A4API?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E7=9A=84=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了ApiKeyResponse、CreateApiKeyRequest中的scope字段 - 移除了ApiKeyGenerator中与作用域相关的生成逻辑 - 更新了ApiKeyService中创建和重新生成密钥的方法实现 - 移除了AuthFilter中对管理接口和开放接口的作用域验证 - 删除了数据库模型中auth_api_key表的scope字段定义 - 修改了UI界面中的表单和表格列配置,移除作用域选择器 - 更新了端到端测试中请求数据的作用域字段 - 调整了项目基础域名配置为Optional类型并更新相关引用 --- .../java/dev/flexmodel/auth/dto/ApiKeyResponse.java | 2 -- .../dev/flexmodel/auth/dto/CreateApiKeyRequest.java | 1 - .../dev/flexmodel/auth/service/ApiKeyGenerator.java | 7 +++---- .../java/dev/flexmodel/auth/service/ApiKeyService.java | 6 ++---- .../java/dev/flexmodel/common/FlexmodelConfig.java | 10 +++++----- .../flexmodel/common/config/web/filter/AuthFilter.java | 10 ++-------- .../flexmodel/projectauth/EdgeValidateResource.java | 2 -- .../java/dev/flexmodel/settings/GlobalResource.java | 2 +- .../src/main/resources/application.properties | 4 ++-- flexmodel-server/src/main/resources/platform.fml | 1 - .../java/dev/flexmodel/pages/PageAliasManagerTest.java | 4 ++-- .../java/dev/flexmodel/pages/PageDeployerTest.java | 4 ++-- .../java/dev/flexmodel/rest/ApiKeyResourceTest.java | 5 ----- flexmodel-ui | 2 +- 14 files changed, 20 insertions(+), 40 deletions(-) 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 b733719..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 scope; 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.setScope(entity.getScope()); 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 481f4c0..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 scope, 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 841135d..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 @@ -10,6 +10,7 @@ /** * API Key 生成工具。 * 生成格式: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 scope admin / open * @return 包含明文、SHA-256 哈希和前缀的 GeneratedKey */ - public static GeneratedKey generate(String scope) { + 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_" + scope + "_" + 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 87cb913..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 scope = request.scope() != null ? request.scope() : "open"; - ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(scope); + ApiKeyGenerator.GeneratedKey generated = ApiKeyGenerator.generate(); AuthApiKey entity = new AuthApiKey(); entity.setName(request.name()); entity.setKeyHash(generated.hash()); entity.setKeyPrefix(generated.prefix()); - entity.setScope(scope); 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.getScope()); + 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 a39e1cf..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,9 +23,10 @@ public interface FlexmodelConfig extends Serializable { /** * Project base domain, used for CORS and URL construction in subdomain routing mode. + * 空表示 path 模式;配置了真实域名则走 subdomain 模式。 */ @WithName("project-base-domain") - String projectBaseDomain(); + Optional projectBaseDomain(); /** * 路由模式由 {@link #projectBaseDomain()} 自动推断,无需显式配置: @@ -35,8 +36,7 @@ public interface FlexmodelConfig extends Serializable { * */ default boolean isSubdomainRouting() { - String domain = projectBaseDomain(); - return domain != null && !domain.isBlank(); + return projectBaseDomain().map(d -> !d.isBlank()).orElse(false); } /** @@ -46,7 +46,7 @@ default boolean isSubdomainRouting() { */ default String edgeUrlTemplate() { if (isSubdomainRouting()) { - return "https://{{projectId}}." + projectBaseDomain() + return "https://{{projectId}}." + projectBaseDomain().orElse("") + "/api/open/{{projectId}}/functions/{{name}}/invoke"; } return "/api/open/{{projectId}}/functions/{{name}}/invoke"; @@ -59,7 +59,7 @@ default String edgeUrlTemplate() { */ default String pagesUrlTemplate() { if (isSubdomainRouting()) { - return "https://{{projectId}}." + projectBaseDomain(); + 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 ba3b799..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 @@ -160,16 +160,13 @@ private boolean trySystemJwt(String token, ContainerRequestContext requestContex } /** - * 尝试 admin scope API Key 验证。 + * 尝试 API Key 验证(admin 接口面)。 */ private boolean tryAdminApiKey(String token, ContainerRequestContext requestContext, String projectId) { AuthApiKey apiKey = apiKeyService.validate(token); if (apiKey == null) { return false; } - if (!"admin".equals(apiKey.getScope())) { - return false; // open scope key cannot access admin surface - } if (!isProjectAllowed(apiKey, projectId)) { return false; } @@ -182,16 +179,13 @@ private boolean tryAdminApiKey(String token, ContainerRequestContext requestCont // ============================================================ /** - * 尝试 open scope API Key 验证。 + * 尝试 API Key 验证(open 接口面)。 */ private boolean tryOpenApiKey(String token, ContainerRequestContext requestContext, String projectId) { AuthApiKey apiKey = apiKeyService.validate(token); if (apiKey == null) { return false; } - if (!"open".equals(apiKey.getScope())) { - return false; // admin scope key cannot access open surface - } if (!isProjectAllowed(apiKey, projectId)) { return false; } diff --git a/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java b/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java index 340b858..789b8ba 100644 --- a/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java +++ b/flexmodel-server/src/main/java/dev/flexmodel/projectauth/EdgeValidateResource.java @@ -126,8 +126,6 @@ private EdgeValidateResponse tryInvokeToken(String token, String projectId) { private EdgeValidateResponse tryApiKey(String token, String projectId, String functionName) { AuthApiKey apiKey = apiKeyService.validate(token); if (apiKey == null) return null; - // Only open scope keys can directly invoke functions via Deno - if (!"open".equals(apiKey.getScope())) return null; String authToken = internalTokenService.signToken(projectId); String invokeId = "ak-" + UUID.randomUUID().toString().substring(0, 8); 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 3490d98..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,7 +66,7 @@ public Map getProfile() { "settings", settingsService.getSettings(), "apiRootPath", config.apiRootPath(), "storageProvider", storageProvider.getProviderInfo(), - "projectBaseDomain", config.projectBaseDomain(), + "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 237e2ec0..1aa85dd 100644 --- a/flexmodel-server/src/main/resources/application.properties +++ b/flexmodel-server/src/main/resources/application.properties @@ -52,9 +52,9 @@ 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 -# Project base domain — leave empty for path mode (/pages/{projectId}), +# 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.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}) diff --git a/flexmodel-server/src/main/resources/platform.fml b/flexmodel-server/src/main/resources/platform.fml index 4178f4f..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)"), - scope : String @length("20") @default("open") @comment("权限范围: admin/open"), 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 1092105..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,8 +210,8 @@ public PagesConfig pages() { public String projectUrlTemplate() { return ""; } @Override - public String projectBaseDomain() { - return ""; + public java.util.Optional projectBaseDomain() { + return java.util.Optional.empty(); } @Override 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 5832dfa..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,8 +239,8 @@ public PagesConfig pages() { public String projectUrlTemplate() { return ""; } @Override - public String projectBaseDomain() { - return ""; + public java.util.Optional projectBaseDomain() { + return java.util.Optional.empty(); } @Override 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 63bd47b..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", - "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -85,7 +84,6 @@ void testCreateReadOnlyApiKey() { .body(""" { "name": "E2E只读Key", - "scope": "open", "projectIds": "dev_test", "readOnly": true } @@ -120,7 +118,6 @@ void testRegenerateApiKey() { .body(""" { "name": "E2E重新生成Key", - "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -168,7 +165,6 @@ void testDeleteApiKey() { .body(""" { "name": "E2E待删除Key", - "scope": "open", "projectIds": "dev_test", "readOnly": false } @@ -211,7 +207,6 @@ void testCompleteApiKeyCrudFlow() { .body(""" { "name": "E2E CRUD Key", - "scope": "open", "projectIds": "dev_test", "readOnly": false } diff --git a/flexmodel-ui b/flexmodel-ui index 44f6e9f..a9836ea 160000 --- a/flexmodel-ui +++ b/flexmodel-ui @@ -1 +1 @@ -Subproject commit 44f6e9f6446470b416ad2108d7af95cb1ae7a72b +Subproject commit a9836ea93babc63038ce72d4ad23a954e0350150 From 15a2bfa51e7b71bc03ea5e8f85c8034a95230669 Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Thu, 13 Aug 2026 15:45:51 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor(i18n):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E6=9D=83=E9=99=90=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除了英文配置中的 scope、scope_open 和 scope_admin 字段 - 删除了中文配置中的 scope、scope_open 和 scope_admin 字段 - 清理了不再需要的国际化翻译资源 - 减少了语言包文件大小和内存占用 --- flexmodel-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmodel-ui b/flexmodel-ui index a9836ea..8d0b601 160000 --- a/flexmodel-ui +++ b/flexmodel-ui @@ -1 +1 @@ -Subproject commit a9836ea93babc63038ce72d4ad23a954e0350150 +Subproject commit 8d0b6013dd2b421e2ab6eb4de7ab9a2e024afc0e From dfcd14b75c62de500a4d70a95be17da2b36bcfaa Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Thu, 13 Aug 2026 16:16:14 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(test):=20=E4=BF=AE=E5=A4=8D=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=E4=B8=AD=E7=9A=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E8=BF=9E=E6=8E=A5=E6=B1=A0=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=B8=85=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为数据库连接池配置添加最大大小限制(5) - 修复连接工厂配置的链式调用顺序 - 在关闭会话前检查会话是否为空,避免空指针异常 - 确保测试资源得到正确释放,防止内存泄漏 --- .../dev/flexmodel/graphql/AbstractIntegrationTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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(); + } } } From 0215ae65c2e4c5efa534242590d37556513b224d Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Thu, 13 Aug 2026 16:27:16 +0800 Subject: [PATCH 10/10] =?UTF-8?q?test(api):=20=E6=9B=B4=E6=96=B0=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=B5=84=E6=BA=90=E5=92=8C=E6=9D=83=E9=99=90=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=99=A8=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 PageResourceTest 中对 customDomains 字段的断言,统一使用 status 断言 - 修改 PermissionFilterTest 删除 functionViewDenyInvoke 测试方法 - 调整相关测试用例以保持状态码和响应体验证的一致性 --- .../java/dev/flexmodel/rest/PageResourceTest.java | 12 +++++------- .../dev/flexmodel/rest/PermissionFilterTest.java | 14 -------------- 2 files changed, 5 insertions(+), 21 deletions(-) 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())