Skip to content

子应用接入规范:AppInstallService 与 AppLinkProvider

适用于所有以 nuwa 应用商店子应用 形态交付的 Java 微服务 (vem、shop 等;hiapi-cloud-public 是平台侧,不适用)。

一句话

新建子应用必须同时实现 AppInstallServiceAppLinkProvider, 放在 <svc>-app 模块的 cn.hiapi.install 包下。 缺 AppInstallService,应用商店的安装/升级/卸载会 500; 缺 AppLinkProvider,商户在装修器里选不到你的任何页面。

1. 两个接口分别管什么

接口位置管什么缺了会怎样
AppInstallServicecn.hiapi.core.install应用商店安装/升级/卸载时回调子应用安装动作 500(见 §4 的真实后果)
AppLinkProvidercn.hiapi.core.install.link向装修器公布"本应用有哪些可跳转页面"装修器链接选择器里一个页面都选不到

调用入口都在框架的 InstallController(hiapi-core-application):

POST /install    /upgrade    /uninstall     → AppInstallService
GET  /app/links  /app/link-options          → AppLinkProvider

2. AppInstallService 怎么写

继承 AbsAppInstallService —— 它已经实现了三个状态查询 (onInstallStatus / onUpdateStatus / onUninstallStatus)和 getAppId() / getServiceId() / getVersion()(分别读配置 spring.hiapi.cloud-app.app-idspring.application.namespring.application.version)。 子应用只需要写三个动作:

java
@Service
public class InstallService extends AbsAppInstallService implements AppInstallService {

    public InstallService(DispatchContext dispatchContext) {
        super(dispatchContext);
    }

    /** 落一条动作记录(status=2 成功),应用商店轮询状态时读它 */
    private void record(AppStatusType action) {
        AppInstallEntity entity = new AppInstallEntity();
        entity.setAppId(this.getAppId());
        entity.setAction(action);
        entity.setServerId(this.getServiceId());
        entity.setStatus(2);
        this.dispatchContext.getServiceOne(AppInstallJpa.class).save(entity);
    }

    @Override
    public ResponseEntity<?> onInstall(AppInstallParams params) {
        String lock = LockConst.getLock("app_install", this.getAppId());
        this.dispatchContext.getServiceOne(ILockService.class).lockThr(lock);
        try {
            record(AppStatusType.Install);
            return ResUtils.toSuccess();
        } finally {
            this.dispatchContext.getServiceOne(ILockService.class).unlock(lock);
        }
    }
    // onUpdate / onUninstall 同构
}

三条要点:

  1. 必须加 Redis 锁。应用商店可能重试,LockConst.getLock("app_install", appId) 把同一 appId 的并发安装串行化。
  2. 动作要幂等。同一应用可能被重复安装/升级,onInstall 里做的事(建默认数据等) 必须能重复执行而不出错。
  3. 卸载要想清楚删不删业务数据。带资金流水的应用(商城的订单/结算/售后) 不要删 —— 删了对账和纠纷处理就没依据了。商城的做法是只记一条卸载记录,数据保留。

3. AppLinkProvider 怎么写

装修器配跳转链接时,public 会实时问子应用要目录。相比 vite.config.tsUploadPlugin({pages:[...]}) 的静态清单,它多了一件关键能力:带参数的页面

商品详情、设备详情这类页面要求商户先选"具体是哪个",静态清单表达不了。

java
@Service
public class ShopLinkProvider implements AppLinkProvider {

    private static final String SOURCE_PRODUCT = "shop.product";

    @Override
    public List<AppLinkGroup> links(AppLinkQuery query) {
        return List.of(
            AppLinkGroup.of("browse", "浏览", List.of(
                item("shop.search", "商品搜索", "/subShop/pages/search/index", false),
                withProduct(item("shop.product.detail", "商品详情",
                                 "/subShop/pages/product/detail", false))
            )),
            AppLinkGroup.of("trade", "交易", List.of(
                item("shop.cart", "购物车", "/subShop/pages/cart/index", true)
            ))
        );
    }

    /** 参数候选项:装修器渲染成可搜索下拉框 */
    @Override
    public LinkOptionPage options(LinkOptionQuery query) {
        if (!SOURCE_PRODUCT.equals(query.getOptionSource())) {
            return LinkOptionPage.empty();
        }
        // ⚠️ 必须按 query.getMid() 过滤,否则商户会看到别的租户的数据
        QueryWrapper wrapper = new QueryWrapper()
                .eq("mid", query.getMid())
                .eq("status", ProductStatus.ON_SALE);
        // ... findPage → LinkOptionPage.of(options, total)
    }
}

四条要点:

  1. options() 必须按 query.getMid() 做租户隔离。这是个不走 TokenGet 的入口(mid 由调用方传入),check-tenant.sh 也未必扫得到,漏了就是跨租户数据泄漏。
  2. 候选项只给"链接指向后确实可用"的数据。商城只给 ON_SALE 的商品、 NORMAL 的店铺 —— 让商户能选一个下架商品,等于让他配了个死链。
  3. needLogin 要标准确。公开页(搜索/分类/商品详情)false, 需 user token 的页(购物车/订单/售后/收藏)true。装修器据此决定是否先引导登录。
  4. 参数 key 要和页面 onLoad 读的 query 名一致。商城页面读 id, 所以 LinkParam.setKey("id")

4. 「强制实现」的真实情况(2026-08-13 核实)

规范上两个都必须实现。但代码里的强制点当前是失效的,别指望它兜底:

  • 强制检查写在 BasicCloudApplication.run():
    java
    DispatchContext.getBean(AppInstallService.class);   // 取不到 → 启动失败
  • 全仓没有任何服务继承 BasicCloudApplication —— vem / user / finance / public / store / shop / task-worker 的启动类清一色 extends BasicApplication, 而 BasicApplication 里没有这个检查。

所以缺 AppInstallService 的真实后果不是"启动失败",而是:

服务正常启起来,直到应用商店调 /installdispatchContext.getServiceOne(AppInstallService.class) 返回 null → NPE 500。 也就是装到一半才炸,比启动失败难查得多。

AppLinkProvider 的两个方法都是 default 空实现,技术上更不会拦你 —— InstallController 里对 provider 为 null 做了判空,返回空数组。

结论:把这两个当成新建子应用的 checklist 项,靠人守,不要靠框架守。 如果要让框架重新守住,得让子应用启动类改继承 BasicCloudApplication (会影响所有服务的启动行为,属于框架层决策,尚未做)。

5. 现状台账(2026-08-13)

服务AppInstallServiceAppLinkProvider
hiapi-cloud-vemVemLinkProvider
hiapi-cloud-shopShopLinkProvider
hiapi-cloud-user
hiapi-cloud-finance
hiapi-cloud-public❌(平台侧,不作为子应用交付)
hiapi-cloud-store
hiapi-task-worker

user / finance 是平台基础服务,不走应用商店安装,没有可跳转的 C 端页面, AppLinkProvider 缺失是合理的。store 的缺失是真缺口 —— 它按子应用形态交付却两个都没有。

6. 新建子应用 checklist

  • [ ] <svc>-app 下建 cn.hiapi.install
  • [ ] InstallService extends AbsAppInstallService implements AppInstallService(三个动作 + Redis 锁 + 幂等)
  • [ ] 卸载是否删业务数据 —— 有资金流水的一律不删
  • [ ] <Svc>LinkProvider implements AppLinkProvider,links() 按业务分组
  • [ ] 带参页面声明 LinkParam,options() 按 mid 隔离且只给可用数据
  • [ ] needLogin 标准确
  • [ ] 配置 spring.hiapi.cloud-app.app-id(应用商店申请的专属 appId,不要复用别的应用的)