开放平台
开放平台
业务用途
开放平台让外部系统(移动端 SDK、第三方应用、前台官网等)无需登录即可通过 API 访问平台数据。平台提供三类开放能力:
- 表级 Open API:通过
publicCode定位表配置,支持新增、更新、查询记录,Token 鉴权 + 字段白名单 - 公众内容接口:面向基金会官网等前台站点,只读公开内容 + 线索提交(志愿者报名/实习申请等),IP 限流 + 字段脱敏
- 应用公开查询:通过
appCode查询已发布应用的基础信息(用于应用登录页展示品牌信息)
此外还提供 API Key 鉴权 和 Webhook 事件通知 两个基础设施。
涉及文件
| 层 | 文件路径 | 说明 |
|---|---|---|
| Controller | backend/src/main/java/com/lowcode/controller/OpenTableApiController.java | /api/open/tables/{publicCode} 表级 Open API |
| Controller | backend/src/main/java/com/lowcode/controller/OpenContentController.java | /api/open/foundation 公众内容接口 |
| Controller | backend/src/main/java/com/lowcode/controller/OpenAppController.java | /api/open/app 应用公开查询 |
| Controller | backend/src/main/java/com/lowcode/controller/DatabaseController.java | 开放接口配置管理端点 |
| Entity | backend/src/main/java/com/lowcode/entity/LcOpenTableApiConfig.java | 开放接口配置实体 |
| Entity | backend/src/main/java/com/lowcode/entity/LcApiKey.java | API Key 实体 |
| Entity | backend/src/main/java/com/lowcode/entity/LcWebhook.java | Webhook 配置实体 |
| Service | backend/src/main/java/com/lowcode/service/impl/LcOpenTableApiConfigServiceImpl.java | 开放接口配置服务(Token 管理、字段白名单、CRUD 代理) |
| Service | backend/src/main/java/com/lowcode/service/WebhookService.java | Webhook 触发与投递 |
| Service | backend/src/main/java/com/lowcode/service/LcApiKeyService.java | API Key 校验 |
| Filter | backend/src/main/java/com/lowcode/security/ApiKeyAuthFilter.java | API Key 认证过滤器 |
数据库表
lc_open_table_api_config(低代码表开放接口配置表)
CREATE TABLE lc_open_table_api_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
tenant_id BIGINT DEFAULT 1 COMMENT '租户ID',
app_id BIGINT COMMENT '应用ID',
table_id BIGINT NOT NULL COMMENT '表ID',
public_code VARCHAR(64) NOT NULL COMMENT '对外编码',
enabled TINYINT DEFAULT 0 COMMENT '是否启用',
allow_create TINYINT DEFAULT 0 COMMENT '允许新增',
allow_update TINYINT DEFAULT 0 COMMENT '允许更新',
allow_query TINYINT DEFAULT 0 COMMENT '允许查询',
write_auth_required TINYINT DEFAULT 1 COMMENT '写接口是否校验Authorization',
query_auth_required TINYINT DEFAULT 1 COMMENT '查询接口是否校验Authorization',
token_hash VARCHAR(255) COMMENT 'Token哈希',
token_prefix VARCHAR(32) COMMENT 'Token前缀',
allowed_insert_fields TEXT COMMENT '新增字段白名单',
allowed_update_fields TEXT COMMENT '更新字段白名单',
allowed_query_fields TEXT COMMENT '查询字段白名单',
response_fields TEXT COMMENT '返回字段白名单',
max_page_size INT DEFAULT 100 COMMENT '最大分页条数',
last_used_time DATETIME COMMENT '最后使用时间',
remark VARCHAR(500) COMMENT '备注',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
create_by VARCHAR(64),
update_by VARCHAR(64),
deleted TINYINT DEFAULT 0,
UNIQUE KEY uk_open_table_public_code (public_code),
UNIQUE KEY uk_open_table_tenant_table (tenant_id, table_id),
INDEX idx_open_table_tenant_app (tenant_id, app_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='低代码表开放接口配置表';LcOpenTableApiConfig 实体字段
@TableName("lc_open_table_api_config")
public class LcOpenTableApiConfig extends BaseEntity {
private Long appId; // 应用ID
private Long tableId; // 表ID
private String publicCode; // 对外编码(UUID,唯一)
private Integer enabled; // 是否启用
private Integer allowCreate; // 允许新增
private Integer allowUpdate; // 允许更新
private Integer allowQuery; // 允许查询
private Integer writeAuthRequired; // 写接口是否校验Token
private Integer queryAuthRequired; // 查询接口是否校验Token
private String tokenHash; // Token哈希(BCrypt)
private String tokenPrefix; // Token前缀(展示用)
private String allowedInsertFields; // 新增字段白名单(JSON数组)
private String allowedUpdateFields; // 更新字段白名单(JSON数组)
private String allowedQueryFields; // 查询字段白名单(JSON数组)
private String responseFields; // 返回字段白名单(JSON数组)
private Integer maxPageSize; // 最大分页条数(1-500)
private LocalDateTime lastUsedTime; // 最后使用时间
private String remark; // 备注
}lc_api_key(API Key 表)
@TableName("lc_api_key")
public class LcApiKey extends BaseEntity {
private Long tenantId; // 租户ID
private String name; // API Key名称
private String apiKey; // API Key值
private String apiSecret; // API Secret值
private String enabled; // 是否启用(0:禁用 1:启用)
private LocalDateTime expireTime; // 过期时间
private LocalDateTime lastUsedTime; // 最后使用时间
private String remark; // 备注
}lc_webhook(Webhook 配置表)
@TableName("lc_webhook")
public class LcWebhook extends BaseEntity {
private String tableName; // 表名
private String event; // 事件类型(INSERT/UPDATE/DELETE)
private String url; // Webhook URL
private String secret; // 密钥(用于签名验证)
private Byte status; // 状态(0:启用 1:禁用)
private Integer retryCount; // 重试次数
private LocalDateTime lastTriggerTime; // 最后触发时间
private String lastError; // 最后错误信息
}后端实现
一、表级 Open API(OpenTableApiController)
端点列表(/api/open/tables/{publicCode})
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/open/tables/{publicCode}/records | 新增记录 |
| PUT | /api/open/tables/{publicCode}/records/{id} | 更新记录 |
| POST | /api/open/tables/{publicCode}/query?pageNum=1&pageSize=20 | 查询记录 |
配置管理端点(DatabaseController)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/database/table/{id}/open-api | 获取开放接口配置 |
| PUT | /api/database/table/{id}/open-api | 保存开放接口配置 |
| POST | /api/database/table/{id}/open-api/token | 生成/轮换 Token |
鉴权流程
// OpenTableApiController -- 以新增记录为例
@PostMapping("/records")
public Result<Map<String, Object>> createRecord(
@PathVariable String publicCode,
@RequestBody(required = false) Map<String, Object> payload,
HttpServletRequest request) {
try {
// 1. 按 publicCode 查找已启用的配置
LcOpenTableApiConfig config = requireConfig(publicCode);
// 2. 校验 Token(如果配置了 writeAuthRequired)
openTableApiConfigService.verifyTokenIfRequired(
config, false, request.getHeader("Authorization"));
// 3. 执行新增(字段白名单过滤 + 复用 SchemaService)
return Result.success(openTableApiConfigService.createRecord(config, payload));
} catch (SecurityException e) {
return Result.unauthorized("Unauthorized");
} catch (Exception e) {
return Result.error(e.getMessage());
}
}Token 校验 -- verifyTokenIfRequired()
public void verifyTokenIfRequired(LcOpenTableApiConfig config, boolean query, String authorizationHeader) {
boolean required = query ? intFlag(config.getQueryAuthRequired()) : intFlag(config.getWriteAuthRequired());
if (!required) return; // 不需要鉴权
if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith("Bearer ")) {
throw new SecurityException("Unauthorized");
}
String token = authorizationHeader.substring(7).trim();
// BCrypt 匹配
if (!StringUtils.hasText(token) || !StringUtils.hasText(config.getTokenHash())
|| !passwordEncoder.matches(token, config.getTokenHash())) {
throw new SecurityException("Unauthorized");
}
}字段白名单过滤 -- filterPayload()
private Map<String, Object> filterPayload(Long tableId, Map<String, Object> payload, List<String> allowedFields) {
Set<String> allowed = new LinkedHashSet<>(allowedFields);
Map<String, LcColumnMeta> columns = schemaService.getTableDetail(tableId).getColumns().stream()
.collect(Collectors.toMap(LcColumnMeta::getJavaField, column -> column, (a, b) -> a, LinkedHashMap::new));
Map<String, Object> data = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : payload.entrySet()) {
String key = entry.getKey();
if ("_queryTypes".equals(key)) continue;
// 系统字段、不存在的字段、不在白名单中的字段 -> 拒绝
if (SYSTEM_FIELDS.contains(key) || !columns.containsKey(key) || !allowed.contains(key)) {
throw new IllegalArgumentException("字段不允许: " + key);
}
data.put(key, entry.getValue());
}
return data;
}三层安全防护
- Token 鉴权:Bearer Token,BCrypt 哈希存储,可按读/写分别配置是否需要鉴权
- 字段白名单:
allowedInsertFields/allowedUpdateFields/allowedQueryFields/responseFields四个白名单,非白名单字段直接拒绝 - 系统字段隔离:
id、tenantId、deleted、createTime等系统字段永远不允许通过 API 写入
Token 生成 -- rotateToken()
public Map<String, Object> rotateToken(Long tableId) {
LcTableMeta table = currentTenantTable(tableId);
LcOpenTableApiConfig config = getOrCreateConfig(table);
String token = generateToken(); // 32 字节 SecureRandom + Base64URL
config.setTokenHash(passwordEncoder.encode(token)); // BCrypt 哈希
config.setTokenPrefix(token.substring(0, Math.min(12, token.length()))); // 前 12 字符用于展示
updateById(config);
return toSafeMap(config, token); // 只有这一次返回明文 token
}二、公众内容接口(OpenContentController)
端点列表(/api/open/foundation)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/open/foundation/{publicCode}/list?pageNum=1&pageSize=10&category=... | 列表查询(公开只读) |
| GET | /api/open/foundation/{publicCode}/{id} | 单条详情 |
| POST | /api/open/foundation/{publicCode}/leads | 提交线索(志愿者报名/实习申请/科技成果转化需求) |
| GET | /api/open/foundation/donations/summary | 捐赠聚合数据(趋势 + 渠道分布 + 地域分布) |
支持的 publicCode 列表
内容类(公开读):
| publicCode | 对应表 | 说明 |
|---|---|---|
site-config | fnd_site_config | 网站配置 |
site-layout | fnd_site_layout | 网站布局 |
banners | fnd_homepage_banner | 首页轮播 |
metrics | fnd_metric | 核心指标 |
about | fnd_about | 关于我们 |
team | fnd_team_member | 团队成员 |
partners | fnd_partner | 合作伙伴 |
honors | fnd_honor | 荣誉 |
party-news | fnd_party_news | 党建新闻 |
projects | fnd_project | 项目 |
news | fnd_news | 新闻动态 |
publications | fnd_publication | 出版物 |
finance-reports | fnd_finance_report | 财务报告 |
donations | fnd_donation | 捐赠记录(脱敏) |
regulations | fnd_regulation | 规章制度 |
volunteer-posts | fnd_volunteer_post | 志愿者岗位 |
intern-posts | fnd_intern_post | 实习岗位 |
faqs | fnd_faq | 常见问题 |
线索类(公开写):
| publicCode | 对应表 | 说明 |
|---|---|---|
volunteer-applies | fnd_volunteer_apply | 志愿者报名 |
intern-applies | fnd_intern_apply | 实习申请 |
tech-demands | fnd_tech_demand | 科技成果转化需求 |
安全策略
OpenContentController 内置五重安全防护:
/**
* 安全策略:
* 1. 表/字段全部白名单制,写死在代码里,与 lc_open_table_api_config 配置无关
* 2. 自动给敏感字段脱敏(手机号/邮箱/审核备注/匿名捐赠人姓名)
* 3. 查询参数白名单,防止用过滤参数嗅探敏感字段
* 4. POST 提交基于 IP 限流(1 分钟最多 5 次)
* 5. 强制锁定为基金会租户上下文,避免被其它租户的同名表干扰
*/IP 限流实现(Redis):
// 线索提交限流:每分钟最多 5 次
String ip = clientIp(request);
String rateKey = "foundation:leads:rate:" + publicCode + ":" + ip;
Long count = stringRedisTemplate.opsForValue().increment(rateKey);
if (count != null && count == 1L) {
stringRedisTemplate.expire(rateKey, Duration.ofMinutes(1));
}
if (count != null && count > 5) {
return Result.error(429, "提交过于频繁,请稍后再试");
}捐赠脱敏:
// 匿名捐赠 -> donorName 改为 "爱心人士"
// 实名捐赠 -> 保留姓 + *** 脱敏
if ("fnd_donation".equals(spec.tableName)) {
Object anon = row.get("anonymous");
if (anon != null && (Integer.valueOf(1).equals(anon) || "1".equals(String.valueOf(anon)))) {
out.put("donorName", "爱心人士");
} else {
Object name = out.get("donorName");
if (name instanceof String && ((String) name).length() >= 2) {
String s = (String) name;
out.put("donorName", s.charAt(0) + maskTail(s.length() - 1));
}
}
}租户锁定:
private <T> T runInFoundationTenant(java.util.function.Supplier<T> action) {
Long saved = TenantContext.getTenantId();
TenantContext.setTenantId(foundationTenantId); // 强制锁定为基金会租户
try {
return action.get();
} finally {
if (saved != null) TenantContext.setTenantId(saved);
else TenantContext.clear();
}
}三、应用公开查询(OpenAppController)
端点列表(/api/open/app)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/open/app/lookup?code= | 按 appCode 或 id 查询已发布应用信息 |
@GetMapping("/lookup")
public Result<Map<String, Object>> lookup(@RequestParam("code") String code) {
// 临时清空租户上下文,避免跨租户访问时查不到
Long savedTenantId = TenantContext.getTenantId();
ApplicationEntity app;
try {
TenantContext.clear();
app = applicationService.lambdaQuery()
.eq(ApplicationEntity::getAppCode, code).one();
if (app == null) {
try { app = applicationService.getById(Long.parseLong(code.trim())); }
catch (NumberFormatException ignored) {}
}
} finally {
if (savedTenantId != null) TenantContext.setTenantId(savedTenantId);
}
if (app == null) return Result.error("应用不存在");
// 仅放行已发布应用
boolean published = "published".equalsIgnoreCase(app.getStatus()) || "1".equals(app.getStatus());
if (!published) return Result.error("应用未发布");
// 仅返回基础信息(id, appCode, appName, icon, description, status, tenantId)
return Result.success(data);
}AppRuntime.vue 中的应用查找
AppRuntime.vue 第 255 行调用 GET /api/open/app/lookup?code= 来查找应用信息,用于应用级登录页展示品牌信息。
四、API Key 认证过滤器(ApiKeyAuthFilter)
@Component
@Order(2)
public class ApiKeyAuthFilter implements Filter {
private static final String API_KEY_HEADER = "X-Api-Key";
private static final String API_SECRET_HEADER = "X-Api-Secret";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String apiKey = httpRequest.getHeader(API_KEY_HEADER);
String apiSecret = httpRequest.getHeader(API_SECRET_HEADER);
// 没有 API Key 头,继续其他过滤器
if (apiKey == null || apiKey.isEmpty()) {
chain.doFilter(request, response);
return;
}
// 验证 API Key
LcApiKey key = apiKeyService.validateApiKey(apiKey, apiSecret);
if (key == null) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("{\"code\":401,\"msg\":\"Invalid API Key or Secret\"}");
return;
}
// 设置租户上下文
TenantContext.setTenantId(key.getTenantId());
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}两种鉴权机制的区别
- API Key 鉴权(
ApiKeyAuthFilter):通过X-Api-Key+X-Api-Secret请求头,适用于全局 API 访问,设置租户上下文后可访问该租户下所有数据。 - Open API Token 鉴权(
verifyTokenIfRequired):通过Authorization: Bearer {token}请求头,仅用于表级 Open API,限定单张表的读写权限 + 字段白名单。
五、Webhook 事件通知(WebhookService)
@Service
public class WebhookService {
private static final int MAX_RETRY = 3;
private static final long INITIAL_BACKOFF_MS = 1000;
// 触发 Webhook 事件
public void trigger(String tableName, String event, Object data) {
List<LcWebhook> webhooks = webhookMapper.selectList(
new LambdaQueryWrapper<LcWebhook>()
.eq(LcWebhook::getTableName, tableName)
.eq(LcWebhook::getEvent, event)
.eq(LcWebhook::getStatus, 0) // 启用状态
);
for (LcWebhook webhook : webhooks) {
deliverWithRetry(webhook, event, data);
}
}
// 带重试的投递(指数退避:1s -> 2s -> 4s)
private void deliverWithRetry(LcWebhook webhook, String event, Object data) {
int attempts = 0;
long backoff = INITIAL_BACKOFF_MS;
while (attempts < MAX_RETRY) {
attempts++;
try {
if (deliver(webhook, event, data)) {
updateWebhookStatus(webhook.getId(), 1, null); // 成功
return;
}
} catch (Exception e) {
updateWebhookStatus(webhook.getId(), 2, e.getMessage()); // 失败
}
if (attempts < MAX_RETRY) {
TimeUnit.MILLISECONDS.sleep(backoff);
backoff *= 2; // 指数退避
}
}
updateWebhookStatus(webhook.getId(), 2, "Max retry attempts exceeded");
}
// 投递 Webhook(POST JSON 到注册 URL)
private boolean deliver(LcWebhook webhook, String event, Object data) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Webhook-Event", event);
headers.set("X-Webhook-Id", String.valueOf(webhook.getId()));
Map<String, Object> payload = new HashMap<>();
payload.put("event", event);
payload.put("table", webhook.getTableName());
payload.put("timestamp", LocalDateTime.now().toString());
payload.put("data", data);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(payload, headers);
restTemplate.postForEntity(webhook.getUrl(), request, String.class);
return true;
}
}前端实现
开放平台没有独立的前端设计界面,配置通过表设计器的「开放接口配置」面板完成:
// frontend/src/api/tableDesigner.ts
export function getOpenApiConfig(tableId: number) {
return request.get(`/database/table/${tableId}/open-api`).then((res: any) => res.data)
}
export function saveOpenApiConfig(tableId: number, data: any) {
return request.put(`/database/table/${tableId}/open-api`, data).then((res: any) => res.data)
}
export function rotateOpenApiToken(tableId: number) {
return request.post(`/database/table/${tableId}/open-api/token`).then((res: any) => res.data)
}AppRuntime.vue 中使用 OpenAppController 查找应用:
// AppRuntime.vue 第 255 行
const res = await request.get('/open/app/lookup', { params: { code } })操作步骤
配置表级 Open API
- 在表设计器中打开一张已建好的表
- 进入「开放接口配置」面板
- 启用接口(
enabled = 1),勾选允许的操作(新增/更新/查询) - 配置字段白名单:选择允许新增/更新/查询/返回的字段
- 点击「生成 Token」,保存返回的明文 Token(仅显示一次)
- 设置最大分页条数(
maxPageSize,默认 100,最大 500) - 保存配置

调用 Open API
# 新增记录
curl -X POST http://localhost:52856/api/open/tables/{publicCode}/records \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"supplierName": "测试供应商", "contactPhone": "13800138000"}'
# 更新记录
curl -X PUT http://localhost:52856/api/open/tables/{publicCode}/records/1 \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"contactPhone": "13900139000"}'
# 查询记录
curl -X POST "http://localhost:52856/api/open/tables/{publicCode}/query?pageNum=1&pageSize=20" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"supplierName": "测试", "_queryTypes": {"supplierName": "LIKE"}}'常见问题
Token 丢失怎么办
Token 明文只在 rotateToken() 调用时返回一次,之后只存储 BCrypt 哈希。如果丢失,需要重新调用 POST /api/database/table/{id}/open-api/token 生成新 Token,旧 Token 立即失效。
字段白名单报「字段不允许」
filterPayload() 严格校验:传入的字段必须在白名单中、必须存在于 lc_column_meta、且不能是系统字段(id、tenantId、deleted 等)。请检查请求体中的字段名是否为驼峰且在白名单中。
公众内容接口返回 404
OpenContentController 的 publicCode 必须在 SPECS 静态 Map 中预定义。如果传了未定义的 publicCode,会返回 404 资源不存在。如需新增 publicCode,需要修改 buildSpecs() 方法。
Webhook 投递失败
WebhookService 最多重试 3 次,指数退避(1s -> 2s -> 4s)。如果全部失败,lc_webhook.status 设为 2,lastError 记录错误信息。检查目标 URL 是否可达、是否返回 2xx 状态码。
