feat(feishu): one-click app creation via official SDK device-flow registration

Saves the user the entire 'go to the open platform -> create an enterprise
app -> copy App ID and Secret' detour. Click a button in the channel form,
scan the QR code, confirm authorization, credentials are auto-filled.

Backend
- Bump com.larksuite.oapi:oapi-sdk from 2.5.3 to 2.6.1, which adds the
  scene/registration package wrapping the device-code flow.
- New FeishuAppRegistrationService: each begin() creates a sessionId,
  spawns a worker thread, runs the SDK's blocking RegisterApp.register
  with onQRCode and onStatusChange wired into a per-session state machine
  (PENDING -> WAITING -> CONFIRMED / EXPIRED / DENIED / ERROR). The
  session caches the QR data URI so ZXing only encodes once per attempt.
  Sessions evict after 5 minutes so closed browsers don't leak the map.
- Two new webhook endpoints under /api/v1/channels/webhook/feishu:
  POST /register/begin returns session_id, GET /register/status returns
  status + qrcode_img (data URI base64 PNG, ZXing-encoded from the SDK's
  verification URL — the raw URL would render as a broken image, so the
  encoding step matches the WeCom flow).
- SDK detail caught the hard way: don't pass .domain() or .larkDomain().
  The SDK defaults are accounts.feishu.cn / accounts.larksuite.com (the
  registration endpoints). open.feishu.cn is the open-API endpoint, a
  completely different service. Passing the wrong one makes the SDK parse
  HTML as JSON and emit invalid_response.

Frontend
- channelApi: feishuRegisterBegin / feishuRegisterStatus.
- New useFeishuAppRegister composable: state machine that begins the
  session, polls status every 2s, prefers qrcode_img over qrcode_url for
  the <img> src, stops on terminal status, fires onConfirmed with
  {appId, appSecret}.
- ChannelEditModal: a new feishu-register-card above the wecom one. The
  composable's onConfirmed writes channelConfig.app_id / app_secret, so
  the existing form fields update reactively.
- i18n: channels.feishuRegister.* keys for title / hint / button states /
  scan / confirmed / expired / denied / error.
This commit is contained in:
matevip 2026-04-28 11:11:36 +08:00
parent 4081469e15
commit a27898507c
8 changed files with 435 additions and 1 deletions

View File

@ -202,7 +202,7 @@
<dependency>
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<version>2.5.3</version>
<version>2.6.1</version>
</dependency>
<!-- ===== Caffeine Cache用于 skill runtime 缓存) ===== -->

View File

@ -10,6 +10,7 @@ import vip.mate.channel.ChannelAdapter;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.dingtalk.DingTalkChannelAdapter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuAppRegistrationService;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.telegram.TelegramChannelAdapter;
import vip.mate.channel.weixin.ILinkClient;
@ -47,6 +48,7 @@ import java.util.Optional;
public class ChannelWebhookController {
private final ChannelManager channelManager;
private final FeishuAppRegistrationService feishuAppRegistrationService;
@Operation(summary = "钉钉消息回调")
@PostMapping("/dingtalk")
@ -80,6 +82,62 @@ public class ChannelWebhookController {
return ResponseEntity.ok(Map.of("code", 0));
}
// ==================== 飞书一键应用注册oapi-sdk 2.6+ ====================
@Operation(summary = "启动飞书扫码注册应用流程")
@PostMapping("/feishu/register/begin")
public ResponseEntity<Map<String, Object>> feishuRegisterBegin(
@RequestParam(value = "domain", defaultValue = "feishu") String domain) {
try {
String sessionId = feishuAppRegistrationService.begin(domain);
return ResponseEntity.ok(Map.of("session_id", sessionId));
} catch (Exception e) {
log.error("[feishu-register] begin failed: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to start registration: " + e.getMessage()));
}
}
@Operation(summary = "查询飞书扫码注册状态")
@GetMapping("/feishu/register/status")
public ResponseEntity<Map<String, Object>> feishuRegisterStatus(@RequestParam("session") String sessionId) {
FeishuAppRegistrationService.RegistrationSession session = feishuAppRegistrationService.getSession(sessionId);
if (session == null) {
return ResponseEntity.ok(Map.of("status", "expired", "error", "session not found or expired"));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", session.status.name().toLowerCase());
if (session.qrcodeUrl != null) {
body.put("qrcode_url", session.qrcodeUrl);
body.put("qrcode_expire_seconds", session.qrcodeExpireSeconds);
// SDK gives us a verification URL string (verification_uri_complete);
// browsers can't render that as an image, so encode it into a PNG QR
// here just like the WeChat flow does. Cache the encoded image on the
// session so we only run ZXing once per registration attempt.
if (session.qrcodeImgDataUri == null) {
try {
String base64 = generateQrCodeBase64(session.qrcodeUrl);
session.qrcodeImgDataUri = "data:image/png;base64," + base64;
} catch (Exception e) {
log.warn("[feishu-register] QR encode failed: {}", e.getMessage());
}
}
if (session.qrcodeImgDataUri != null) {
body.put("qrcode_img", session.qrcodeImgDataUri);
}
}
if (session.status == FeishuAppRegistrationService.Status.CONFIRMED) {
body.put("client_id", session.clientId);
body.put("client_secret", session.clientSecret);
if (session.userOpenId != null) body.put("user_open_id", session.userOpenId);
if (session.userTenantBrand != null) body.put("user_tenant_brand", session.userTenantBrand);
}
if (session.errorMessage != null) {
body.put("error", session.errorMessage);
}
return ResponseEntity.ok(body);
}
@Operation(summary = "Telegram 消息回调")
@PostMapping("/telegram")
public ResponseEntity<String> telegramWebhook(@RequestBody Map<String, Object> payload) {

View File

@ -0,0 +1,176 @@
package vip.mate.channel.feishu;
import com.lark.oapi.scene.registration.AccessDeniedException;
import com.lark.oapi.scene.registration.ExpiredException;
import com.lark.oapi.scene.registration.QRCodeInfo;
import com.lark.oapi.scene.registration.RegisterApp;
import com.lark.oapi.scene.registration.RegisterAppOptions;
import com.lark.oapi.scene.registration.RegisterAppResult;
import com.lark.oapi.scene.registration.StatusChangeInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 飞书"一键应用注册"服务
* <p>
* 包装 oapi-sdk 2.6.x {@link RegisterApp#register} 流程用户扫码后飞书侧自动建好
* 自建应用并把 client_id/client_secret 回传省掉用户手动到开放平台建应用 + 复制 ID/Secret
* 的步骤
* <p>
* SDK register() 是阻塞的内部轮询直到扫码确认或超时所以这里用一个工作线程执行
* QR URL 通过 onQRCode 回调写到 session前端按 token 轮询 status 端点查最终结果
*
* @author MateClaw Team
*/
@Slf4j
@Service
public class FeishuAppRegistrationService {
/** 会话最长存活时间5 分钟QR 自身约 3 分钟过期,留足缓冲让前端读到 expired 状态) */
private static final long SESSION_TTL_MS = 5 * 60_000L;
/** session_id → 注册会话状态 */
private final ConcurrentHashMap<String, RegistrationSession> sessions = new ConcurrentHashMap<>();
/**
* 启动一次注册流程spawn 后台线程跑 SDK register()立即返回 sessionId
* QR URL onQRCode 回调里被写入 session调用方需要轮询 {@link #getSession(String)}
*
* @param domainKey "feishu"国内 "lark"国际 仅用作记录
* SDK 内部会把 domain/larkDomain 都试一遍QR 二维码扫码端
* 本来就支持飞书 / Lark 两种 App 互通所以这里**不传**
* 域名让 SDK 自动用默认 accounts.feishu.cn / accounts.larksuite.com
* 之前误传开放平台域名 https://open.feishu.cn 会让 SDK 拿到
* HTML JSON 解析直接 invalid_response
* @return 新创建的 sessionId前端拿这个去查询 QR / status
*/
public String begin(String domainKey) {
evictExpiredSessions();
String sessionId = UUID.randomUUID().toString();
RegistrationSession session = new RegistrationSession(sessionId);
sessions.put(sessionId, session);
// 不传 .domain()/.larkDomain()SDK 默认用 accounts.feishu.cn accounts.larksuite.com
// 这两个是注册账号端点open.feishu.cn 是开放 API 端点 完全不是一个东西
RegisterAppOptions options = RegisterAppOptions.newBuilder()
.source("mateclaw")
.onQRCode(qr -> session.onQRCode(qr))
.onStatusChange(status -> session.onStatusChange(status))
.build();
Thread worker = new Thread(() -> {
try {
RegisterAppResult result = RegisterApp.register(options);
session.onSuccess(result);
} catch (ExpiredException e) {
session.onTerminal(Status.EXPIRED, "QR code expired");
} catch (AccessDeniedException e) {
session.onTerminal(Status.DENIED, "User denied authorization");
} catch (Exception e) {
log.warn("[feishu-register] register() failed: {}", e.getMessage());
session.onTerminal(Status.ERROR, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
}
}, "feishu-register-" + sessionId.substring(0, 8));
worker.setDaemon(true);
worker.start();
return sessionId;
}
public RegistrationSession getSession(String sessionId) {
evictExpiredSessions();
return sessions.get(sessionId);
}
/**
* Drop sessions that have lived past SESSION_TTL_MS. Keeps the map bounded
* even if the user closes the browser mid-flow without ever polling the
* terminal state.
*/
private void evictExpiredSessions() {
long cutoff = System.currentTimeMillis() - SESSION_TTL_MS;
Iterator<Map.Entry<String, RegistrationSession>> it = sessions.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().createdAtMs < cutoff) it.remove();
}
}
public enum Status {
/** Worker started but onQRCode hasn't fired yet — QR not ready */
PENDING,
/** QR available, polling for user scan + confirmation */
WAITING,
/** User confirmed, clientId/clientSecret returned */
CONFIRMED,
/** QR expired before user finished scanning */
EXPIRED,
/** User denied authorization on the feishu side */
DENIED,
/** Network or SDK error */
ERROR
}
/**
* 一次注册流程的可观察状态线程安全所有写操作都在 SDK 工作线程读操作在 HTTP 轮询线程
* 字段全部 volatile 即可
*/
public static class RegistrationSession {
public final String sessionId;
final long createdAtMs = System.currentTimeMillis();
public volatile Status status = Status.PENDING;
public volatile String qrcodeUrl;
public volatile int qrcodeExpireSeconds;
/** Cached "data:image/png;base64,..." rendered from qrcodeUrl by the controller. */
public volatile String qrcodeImgDataUri;
public volatile String clientId;
public volatile String clientSecret;
public volatile String userOpenId;
public volatile String userTenantBrand;
public volatile String errorMessage;
public volatile long lastUpdateMs = System.currentTimeMillis();
RegistrationSession(String sessionId) {
this.sessionId = sessionId;
}
void onQRCode(QRCodeInfo qr) {
this.qrcodeUrl = qr.getUrl();
this.qrcodeExpireSeconds = qr.getExpireIn();
this.status = Status.WAITING;
this.lastUpdateMs = System.currentTimeMillis();
log.info("[feishu-register] QR ready, expires in {}s", qr.getExpireIn());
}
void onStatusChange(StatusChangeInfo info) {
// SDK 状态码POLLING / SLOW_DOWN / DOMAIN_SWITCHED三种都是"还在等"没有更细的
// "已扫码未确认"信号 飞书侧扫码确认在同一步完成我们只用 lastUpdateMs
// 前端知道连接还活着状态本身保持 WAITING 直到终态
this.lastUpdateMs = System.currentTimeMillis();
}
void onSuccess(RegisterAppResult result) {
this.clientId = result.getClientId();
this.clientSecret = result.getClientSecret();
if (result.getUserInfo() != null) {
this.userOpenId = result.getUserInfo().getOpenId();
this.userTenantBrand = result.getUserInfo().getTenantBrand();
}
this.status = Status.CONFIRMED;
this.lastUpdateMs = System.currentTimeMillis();
log.info("[feishu-register] confirmed, clientId={}", clientId);
}
void onTerminal(Status terminalStatus, String message) {
this.status = terminalStatus;
this.errorMessage = message;
this.lastUpdateMs = System.currentTimeMillis();
}
}
}

View File

@ -232,6 +232,11 @@ export const channelApi = {
weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'),
weixinQrcodeStatus: (qrcode: string) =>
http.get(`/channels/webhook/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`),
// Feishu one-click app registration (oapi-sdk 2.6+ scene/registration)
feishuRegisterBegin: (domain: string) =>
http.post(`/channels/webhook/feishu/register/begin?domain=${encodeURIComponent(domain)}`),
feishuRegisterStatus: (sessionId: string) =>
http.get(`/channels/webhook/feishu/register/status?session=${encodeURIComponent(sessionId)}`),
}
// ==================== MCP Server ====================

View File

@ -116,6 +116,40 @@
</div>
</div>
<!-- 飞书一键应用注册oapi-sdk 2.6+ -->
<div v-if="form.channelType === 'feishu'" class="feishu-register-card">
<div class="feishu-register-header">
<strong>{{ t('channels.feishuRegister.title') }}</strong>
</div>
<p class="feishu-register-hint">{{ t('channels.feishuRegister.hint') }}</p>
<button
type="button"
class="feishu-register-btn"
@click="feishuRegister.start(channelConfig.domain || 'feishu')"
:disabled="feishuRegister.loading.value || feishuRegister.status.value === 'waiting'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/>
<line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/>
<line x1="21" y1="21" x2="21" y2="21"/>
</svg>
{{ feishuRegister.loading.value
? t('channels.feishuRegister.buttonLoading')
: t('channels.feishuRegister.button') }}
</button>
<div v-if="feishuRegister.qrcodeUrl.value" class="feishu-register-qrcode">
<img :src="feishuRegister.qrcodeUrl.value" :alt="t('channels.feishuRegister.button')" class="feishu-register-qrcode-img" />
<p class="feishu-register-status" :class="feishuRegister.status.value">
<template v-if="feishuRegister.status.value === 'confirmed'">{{ t('channels.feishuRegister.confirmed') }}</template>
<template v-else-if="feishuRegister.status.value === 'expired'">{{ t('channels.feishuRegister.expired') }}</template>
<template v-else-if="feishuRegister.status.value === 'denied'">{{ t('channels.feishuRegister.denied') }}</template>
<template v-else-if="feishuRegister.status.value === 'error'">{{ t('channels.feishuRegister.error') }}</template>
<template v-else>{{ t('channels.feishuRegister.scanHint') }}</template>
</p>
</div>
</div>
<!-- 企业微信扫码授权 -->
<div v-if="form.channelType === 'wecom'" class="wecom-auth-card">
<p class="wecom-auth-hint">{{ t('channels.wecom.authHint') }}</p>
@ -361,6 +395,7 @@ import {
} from '@/utils/channelConfigJson'
import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll'
import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth'
import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister'
interface Props {
modelValue: boolean
@ -431,6 +466,13 @@ const wecom = useWecomBotAuth((bot) => {
channelConfig.value.secret = bot.secret
})
// Feishu one-click app registration: scan-to-create flow that returns
// app_id/app_secret without the user ever touching the developer console.
const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => {
channelConfig.value.app_id = appId
channelConfig.value.app_secret = appSecret
})
// ========== Field defs (derived) ==========
const currentFieldDefs = computed<ChannelFieldDef[]>(() => {
@ -693,6 +735,22 @@ function save() {
.guide-steps :deep(code) { font-size: 12px; background: var(--mc-bg-sunken); padding: 1px 5px; border-radius: 3px; }
.guide-steps :deep(b) { color: var(--mc-text-primary); font-weight: 600; }
/* Feishu one-click register */
.feishu-register-card { background: linear-gradient(135deg, rgba(0,128,255,0.05), rgba(99,102,241,0.05)); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.feishu-register-header { font-size: 13px; color: var(--mc-text-primary); margin-bottom: 6px; }
.feishu-register-hint { font-size: 12px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; }
.feishu-register-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: #2563eb; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.feishu-register-btn:hover:not(:disabled) { background: #1d4ed8; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(37,99,235,0.3); }
.feishu-register-btn:active:not(:disabled) { transform: translateY(0); }
.feishu-register-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.feishu-register-qrcode { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); }
.feishu-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; }
.feishu-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; }
.feishu-register-status.confirmed { color: #10b981; font-weight: 500; }
.feishu-register-status.expired { color: #f56c6c; }
.feishu-register-status.denied { color: #f56c6c; }
.feishu-register-status.error { color: #f56c6c; }
/* WeCom auth */
.wecom-auth-card { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.2)); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
.wecom-auth-hint { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; }

View File

@ -0,0 +1,113 @@
import { ref, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { channelApi } from '@/api'
export type FeishuRegisterStatus = '' | 'pending' | 'waiting' | 'confirmed' | 'expired' | 'denied' | 'error'
export interface FeishuRegisterResult {
appId: string
appSecret: string
}
/**
* "一键应用注册"
* 1. POST /feishu/register/begin sessionId worker QR
* 2. 2s /feishu/register/status qrcode_url / confirmed
* 3. confirmed / expired / denied / error
*
* useWeixinQrcodePoll SDK oapi-sdk RegisterApp
* iLink Bot HTTP
*/
export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => void) {
const { t } = useI18n()
const qrcodeUrl = ref('')
const loading = ref(false)
const status = ref<FeishuRegisterStatus>('')
let pollTimer: ReturnType<typeof setInterval> | null = null
let confirmedFired = false
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function reset() {
stopPolling()
qrcodeUrl.value = ''
status.value = ''
confirmedFired = false
}
async function start(domain: string) {
reset()
loading.value = true
let sessionId = ''
try {
const res: any = await channelApi.feishuRegisterBegin(domain)
sessionId = res?.data?.session_id || res?.session_id || ''
if (!sessionId) {
ElMessage.error(t('channels.feishuRegister.startFailed'))
return
}
status.value = 'pending'
} catch {
ElMessage.error(t('channels.feishuRegister.startFailed'))
return
} finally {
loading.value = false
}
pollTimer = setInterval(async () => {
try {
const res: any = await channelApi.feishuRegisterStatus(sessionId)
const data = res?.data || res || {}
const s = (data.status as FeishuRegisterStatus) || 'pending'
// Prefer the backend-rendered base64 PNG (data: URI). The raw qrcode_url
// is the verification URL that needs to be encoded into a QR image —
// browsers can't render plain text as an image. Fall back to the URL
// only as a defensive last resort.
const img = data.qrcode_img || data.qrcode_url
if (img && qrcodeUrl.value !== img) {
qrcodeUrl.value = img
}
status.value = s
if (s === 'confirmed') {
if (confirmedFired) return
confirmedFired = true
stopPolling()
const appId = data.client_id || ''
const appSecret = data.client_secret || ''
if (appId && appSecret) {
onConfirmed({ appId, appSecret })
ElMessage.success(t('channels.feishuRegister.confirmed'))
}
return
}
if (s === 'expired') {
stopPolling()
ElMessage.warning(t('channels.feishuRegister.expired'))
} else if (s === 'denied') {
stopPolling()
ElMessage.warning(t('channels.feishuRegister.denied'))
} else if (s === 'error') {
stopPolling()
ElMessage.error(t('channels.feishuRegister.error'))
}
} catch {
// Silent — transient network errors should not abort the loop.
}
}, 2000)
}
onBeforeUnmount(stopPolling)
return { qrcodeUrl, loading, status, start, reset, stopPolling }
}

View File

@ -1643,6 +1643,18 @@ export default {
step4: 'After starting the channel, auto-receives messages via <b>WebSocket long connection</b>, <b>no public IP or callback URL required</b>',
},
},
feishuRegister: {
title: 'One-click Feishu App Creation',
hint: 'Click the button to get a QR code. Scan it with Feishu and confirm authorization — an in-house app will be created in your tenant and the App ID / App Secret will be auto-filled below.',
button: 'Scan to Create App',
buttonLoading: 'Preparing QR code…',
scanHint: 'Scan the QR code above with the Feishu app and confirm authorization',
confirmed: 'App created. App ID and Secret have been auto-filled.',
expired: 'QR code expired, please try again',
denied: 'Authorization was denied',
error: 'Registration failed, please retry',
startFailed: 'Failed to fetch QR code, please check your network',
},
feishu: {
perm: {
message: 'Send and receive messages',

View File

@ -1653,6 +1653,18 @@ export default {
step4: '启动渠道后通过 <b>WebSocket 长连接</b>自动接收消息,<b>无需公网 IP 和回调 URL</b>',
},
},
feishuRegister: {
title: '一键创建飞书应用',
hint: '点击按钮获取二维码使用飞书扫一扫并确认授权后会自动在你的企业里创建自建应用App ID / App Secret 自动填入下方。',
button: '扫码自动创建应用',
buttonLoading: '正在准备二维码…',
scanHint: '请使用飞书 App 扫描上方二维码并确认授权',
confirmed: '应用创建成功App ID 和 Secret 已自动填入',
expired: '二维码已过期,请重新生成',
denied: '授权被拒绝',
error: '注册过程出错,请稍后重试',
startFailed: '获取二维码失败,请检查网络',
},
feishu: {
perm: {
message: '获取与发送单聊、群组消息',