This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 9c270f50b feat(litetopic): create Lite parent topics with an explicit 
TTL (#6644)
9c270f50b is described below

commit 9c270f50bbbe160309338b86bdd568ece2f5bb4f
Author: wizcraft_kris <[email protected]>
AuthorDate: Tue Oct 27 16:15:31 2026 +0800

    feat(litetopic): create Lite parent topics with an explicit TTL (#6644)
    
    Adds the create half of LiteTopic parent-topic lifecycle management.
    
    A Lite parent topic has to carry `+message.type=LITE` and 
`+lite.topic.expiration`
    in the same write, or the broker ends up with a parent that never expires. 
This
    goes through `MetadataService.createTopic`, so ownership, audit and catalog
    behaviour match every other topic create, and the delete half is left for a
    follow-up.
    
    Three points were fixed while landing it:
    
    1. Instance-level capability gate. `guardLiteCreate` now runs after
       `requireWriteInstance` (so it sees the canonical instance name) and calls
       `ownershipGuard.requireInstance` + `requireSupportedProvider`, the same 
pair
       `extendTTL` uses. Without it a direct API call with `type=LITE` could 
create a
       Lite parent on a cloud-vendor instance: 
`AliyunInstanceProvider.createTopic`
       forwards only `messageType` and has no TTL field, so the topic would 
exist
       outside the vendor control plane with no expiry. Cloud vendors now get 
501.
    
    2. An omitted TTL is filled with `DEFAULT_LITE_TTL_MINUTES` (1440) instead 
of
       being forwarded as absent. In rocketmq-common 5.5.0,
       `TopicAttributes.LITE_EXPIRATION_ATTRIBUTE` is
       `LongRangeAttribute("lite.topic.expiration", true, -1, 
DAYS.toMinutes(30), -1)`
       - min -1, max 43200 minutes, default -1 - and a non-positive value means 
the
       topic never expires. The AI tool `rmq.topic.update` can also create a 
topic
       and its input schema has no TTL field, so without a server-side default 
that
       path could create a never-expiring parent. This mirrors how 
`writeQueues` /
       `readQueues` already fall back to 8.
    
    3. Unit divergence documented rather than papered over. The native unit of
       `lite.topic.expiration` is minutes, so create takes minutes; `extendTTL`
       takes milliseconds. Converting create to milliseconds would lose 
precision
       (round(ms/60000)) and 30 days does not fit an int, so both units are 
kept and
       stated together in `docs/api-spec.md` 5.3 / 5.4 / 5.11, in the DTO and VO
       javadoc, and in the front-end field hint, with the conversion bound
       `minutes = clamp(round(millis / 60000), 1, 43200)`.
    
    Contract sync: `TopicVO` exposes `liteTtlMinutes` with 
`@JsonInclude(NON_NULL)`
    so it is absent for non-Lite topics, and `UpdateTopicDTO` binds the field 
so the
    documented "400 when liteTtlMinutes is sent with a non-LITE type" is 
actually
    enforced instead of silently dropped.
    
    Scope note: `liteTopicProvider.isSupported()` probes the nameserver address 
from
    Studio's own configuration, not the target instance's, so the 501 gate is
    cluster-global - the same semantics the existing `GET 
/api/liteTopic/capability`
    endpoint already has. In a multi-instance deployment where the default 
endpoint
    and the target instance are different clusters, the gate can be wrong in 
either
    direction. `docs/api-spec.md` 5.3 states this scope explicitly; making the 
probe
    instance-aware needs `LiteTopicProvider.isSupported(instanceId)` plus a 
resolver,
    which touches the capability endpoint and its contract, so it is left to a
    follow-up.
---
 docs/api-spec.md                                   |  21 +++
 .../studio/instance/topic/CreateTopicDTO.java      |  18 +++
 .../studio/instance/topic/MetadataService.java     |  49 +++++++
 .../rocketmq/studio/instance/topic/TopicVO.java    |  13 ++
 .../studio/instance/topic/UpdateTopicDTO.java      |   8 ++
 .../studio/provider/LiteTopicProvider.java         |  15 +++
 .../provider/apache/RocketMQAdminClientImpl.java   |  29 ++++-
 .../provider/apache/RocketMQLiteTopicProvider.java |   3 -
 .../studio/instance/topic/MetadataServiceTest.java | 143 +++++++++++++++++++++
 .../studio/instance/topic/TopicControllerTest.java |  95 ++++++++++++++
 .../ai/tool/service/ToolInstanceRoutingTest.java   |   3 +-
 .../apache/RocketMQAdminClientImplTest.java        | 107 ++++++++++++++-
 web/src/api/metadata.ts                            |   2 +
 web/src/i18n/translations.ts                       |  13 ++
 .../pages/instance/__tests__/TopicPage.test.tsx    |  79 +++++++++++-
 web/src/pages/instance/topic.tsx                   |  43 ++++++-
 web/src/pages/studio/LiteTopic.tsx                 |  11 ++
 web/src/pages/studio/__tests__/LiteTopic.test.tsx  |  18 ++-
 18 files changed, 653 insertions(+), 17 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index 622640a9a..567d20694 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -979,6 +979,7 @@ GET 
/api/topics?clusterId={clusterId}&type={type}&search={keyword}
 | `tps` | `number` | 当前 TPS |
 | `consumerGroupCount` | `number` | 订阅消费组数 |
 | `remark` | `string` | 备注 |
+| `liteTtlMinutes` | `number` | Lite 父主题 TTL(分钟)。**仅出现在创建请求与创建响应中**:列表 / 分页 / 
详情等读响应不返回该键(服务端从不回填,不把未读取的值当作已知值呈现)。已创建父主题的 TTL 用 5.9 `GET /api/liteTopic/list` 
的 `averageTTL`(毫秒)读取,从未写入过 TTL 的父主题该值为 `null` |
 | `createdAt` | `string` | 创建时间 (ISO 8601) |
 | `updatedAt` | `string` | 更新时间 (ISO 8601) |
 
@@ -1027,6 +1028,19 @@ POST /api/topics/create
 | `readQueues` | `number` | 否 | 读队列数,须 ≥ 0;缺省或非正数时取 8 |
 | `perm` | `string` | 否 | 权限,默认 `RW` |
 | `remark` | `string` | 否 | 备注 |
+| `liteTtlMinutes` | `number` | 否 | Lite 父主题 TTL,**单位分钟**,仅 `type=LITE` 时接受,取值 
1-43200(30 天);缺省时服务端取 `1440`(1 天),与控制台弹窗预填值一致。与 `type` 一并作为创建时属性写入 
broker(`+message.type=LITE` 与 `+lite.topic.expiration=<分钟>`),创建后既不能由本接口也不能由 5.4 
修改 |
+
+**`type=LITE` 的额外校验**(全部在任何写入发生之前失败,不产生 broker 写入与审计记录):
+
+| 场景 | `code` | `message` |
+|------|--------|-----------|
+| 非 `LITE` 类型携带 `liteTtlMinutes` | `400` | `liteTtlMinutes is only accepted 
when type is LITE` |
+| `liteTtlMinutes` 越界(< 1 或 > 43200) | `400` | `liteTtlMinutes must be between 
1 and 43200` |
+| 父主题名含 `$`(保留给 `%LMQ%$parent$liteTopic` 子主题) | `400` | `Lite parent topic 
name must not contain '$': '$' separates the parent topic from its lite topics` 
|
+| 目标实例不是 Apache 厂商(云厂商 OpenAPI 没有 `lite.topic.expiration` 等价物,建出的父主题会停在 broker 
的 `-1` 永不过期默认值上) | `501` | `Cloud resources are not registered for global 
ownership yet; resource writes are not allowed` |
+| 集群不支持 LiteTopic(能力探测与 5.13 同源,走 Studio 配置的默认接入点,非目标实例接入点) | `501` | 
`LiteTopic is not supported by this provider` |
+
+**单位口径**:本接口的 `liteTtlMinutes` 用**分钟**(broker 属性 `lite.topic.expiration` 
的原生单位,写入无损);同一资源上 5.11 `POST /api/liteTopic/extendTTL` 的 `newTTL` 用**毫秒**,换算边界为 
`分钟 = clamp(round(毫秒 / 60000), 1, 43200)`(亚分钟精度四舍五入到分钟,并夹在 1 分钟与 30 天之间)。
 
 **Response `data`:** `Topic`
 
@@ -1049,6 +1063,9 @@ POST /api/topics/update
 | `readQueues` | `number` | 否 | 读队列数,须 ≥ 0;缺省或非正数时保留现值 |
 | `perm` | `string` | 否 | 权限。Apache:显式传值即更新权限;省略时各既有 Broker 保留自身运行时的权限掩码(含 
Studio 不可编辑的位)、库内存值不变;新增 Broker 用库内存值兜底,取不到时用 `RW` |
 | `remark` | `string` | 否 | 备注 |
+| `liteTtlMinutes` | `number` | 否 | **恒定拒绝**:该字段是创建专用字段,更新请求携带它一律返回 
`400`(见下)。DTO 仍然绑定它,为的是给出明确错误而不是把未知属性静默丢弃 |
+
+`liteTtlMinutes` 是创建专用字段:更新请求携带该字段返回 `400 liteTtlMinutes is create-only; use 
the LiteTopic TTL extension to change it`;更新路径也从不把 `lite.topic.expiration` 放进 
broker 写增量(增量里只有 `+message.type`),已存在的 TTL 因此不被改写。调整 TTL 走 5.11,注意那边 `newTTL` 
的单位是**毫秒**,与本接口创建时的**分钟**不同。
 
 **Response `data`:** `Topic`
 
@@ -1217,6 +1234,10 @@ POST /api/liteTopic/extendTTL
 | `topicPattern` | `string` | 是 | LiteTopic 模式 |
 | `newTTL` | `number` | 是 | 新的 TTL(毫秒),必须为正数 |
 
+**单位口径**:`newTTL` 用**毫秒**,与 5.3 创建 Lite 父主题时的 
`liteTtlMinutes`(**分钟**)不同——两者操作的是同一个 broker 属性 `lite.topic.expiration`。换算边界为 
`分钟 = clamp(round(毫秒 / 60000), 1, 43200)`:亚分钟精度四舍五入到分钟,结果夹在 1 分钟与 30 天之间,因此 
`newTTL` 小于 30000 毫秒一律落成 1 分钟、大于 2592000000 毫秒一律落成 43200 分钟。
+
+**实例门禁**:父主题只能属于 Apache 厂商实例(与 5.3 的 `type=LITE` 创建一致);非 Apache 厂商返回 
`501`,父主题未在 Studio 注册返回 `409`,父主题在目标集群上不存在返回 `404`。多 master 部分写入失败返回 
`502`,`message` 中列出已改写的 master、失败的 master 与未尝试的 master;延长操作幂等,重跑即可收敛。
+
 **Response `data`:** `null`
 
 ### 5.12 获取 LiteTopic 配额
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/CreateTopicDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/CreateTopicDTO.java
index 91bb64ddf..28bc175a4 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/CreateTopicDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/CreateTopicDTO.java
@@ -16,11 +16,14 @@
  */
 package org.apache.rocketmq.studio.instance.topic;
 
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.PositiveOrZero;
 import lombok.Data;
 import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import org.apache.rocketmq.studio.common.domain.enums.TopicType;
+import org.apache.rocketmq.studio.provider.LiteTopicProvider;
 
 @Data
 public class CreateTopicDTO {
@@ -36,6 +39,20 @@ public class CreateTopicDTO {
     private TopicPerm perm;
     private String remark;
 
+    /**
+     * TTL of a new Lite parent topic in minutes — only accepted with {@code 
type=LITE} and only on
+     * create; the generic update path never edits it. Omitting it falls back 
to
+     * {@link LiteTopicProvider#DEFAULT_LITE_TTL_MINUTES} instead of the 
broker's own {@code -1}
+     * (never expire) attribute default, which is also what the console dialog 
prefills.
+     *
+     * <p>Unit divergence on the same resource: this create field is in 
minutes while
+     * {@code POST /api/liteTopic/extendTTL} takes {@code newTTL} in 
milliseconds. Both are
+     * documented in {@code docs/api-spec.md} (5.3 and 5.11).
+     */
+    @Min(value = 1, message = "liteTtlMinutes must be between 1 and 43200")
+    @Max(value = LiteTopicProvider.MAX_LITE_TTL_MINUTES, message = 
"liteTtlMinutes must be between 1 and 43200")
+    private Integer liteTtlMinutes;
+
     private String instanceId;
 
     public TopicVO toTopicVO() {
@@ -52,6 +69,7 @@ public class CreateTopicDTO {
         }
         topic.setPerm(perm);
         topic.setRemark(remark);
+        topic.setLiteTtlMinutes(liteTtlMinutes);
         return topic;
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
index 055439569..562fa384e 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/MetadataService.java
@@ -41,6 +41,7 @@ import 
org.apache.rocketmq.studio.provider.apache.MetadataProvider;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
 import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.CsvUtil;
 import org.apache.rocketmq.studio.common.util.MessagePropertyDisplay;
@@ -59,6 +60,7 @@ import 
org.apache.rocketmq.studio.instance.message.MessageRecordVO;
 import org.apache.rocketmq.studio.instance.message.MessageService;
 import org.apache.rocketmq.studio.provider.InstanceProvider;
 import org.apache.rocketmq.studio.provider.InstanceProviderRegistry;
+import org.apache.rocketmq.studio.provider.LiteTopicProvider;
 
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -91,6 +93,7 @@ public class MetadataService {
     private final MessageService messageService;
     private final RuntimeAdminClientResolver runtimeAdminClientResolver;
     private final ResourceOwnershipGuard ownershipGuard;
+    private final LiteTopicProvider liteTopicProvider;
 
     /**
      * Canonicalizes registered instance names and legacy numeric IDs, while 
preserving physical
@@ -170,6 +173,7 @@ public class MetadataService {
         }
         String target = requireWriteInstance(instanceId, new 
Resource(Kind.TOPIC, topic.getName()), false);
         topic.setInstanceId(target);
+        guardLiteCreate(target, topic);
         InstanceProvider provider = resolve(target);
         return executeWithAudit(provider, Operation.CREATE_TOPIC, 
ResourceType.TOPIC, topic.getName(),
                 target, topicDetail(topic), () -> importing
@@ -182,6 +186,10 @@ public class MetadataService {
 
     public TopicVO updateTopic(String instanceId, TopicVO topic) {
         requireTopic(topic);
+        if (topic.getLiteTtlMinutes() != null) {
+            throw new BusinessException(400,
+                    "liteTtlMinutes is create-only; use the LiteTopic TTL 
extension to change it");
+        }
         String target = requireWriteInstance(instanceId, new 
Resource(Kind.TOPIC, topic.getName()), false);
         topic.setInstanceId(target);
         guardImmutableType(target, topic);
@@ -202,6 +210,47 @@ public class MetadataService {
         });
     }
 
+    /**
+     * A Lite parent topic is created through the generic topic dialog. Its 
TTL is the create-time
+     * {@code lite.topic.expiration} attribute in minutes, the {@code '$'} 
name separator is
+     * reserved for the implicit {@code %LMQ%$parent$liteTopic} children, and 
the write needs an
+     * Apache-vendor instance on a lite-capable cluster.
+     *
+     * <p>The instance gate mirrors {@link LiteTopicService#extendTTL}. A 
cloud OpenAPI has no
+     * {@code lite.topic.expiration} equivalent, so a LITE parent created 
there would keep the
+     * broker's own {@code -1} (never expire) default and stay outside that 
vendor's control plane.
+     *
+     * <p>{@code instanceId} is the canonical name resolved by {@code 
requireWriteInstance}, so the
+     * guard sees the same instance the write is about to target.
+     */
+    private void guardLiteCreate(String instanceId, TopicVO topic) {
+        if (topic.getLiteTtlMinutes() != null && topic.getType() != 
TopicType.LITE) {
+            throw new BusinessException(400, "liteTtlMinutes is only accepted 
when type is LITE");
+        }
+        if (topic.getLiteTtlMinutes() != null
+                && (topic.getLiteTtlMinutes() < 1
+                        || topic.getLiteTtlMinutes() > 
LiteTopicProvider.MAX_LITE_TTL_MINUTES)) {
+            throw new BusinessException(400, "liteTtlMinutes must be between 1 
and 43200");
+        }
+        if (topic.getType() != TopicType.LITE) {
+            return;
+        }
+        if (topic.getName().contains("$")) {
+            throw new BusinessException(400,
+                    "Lite parent topic name must not contain '$': '$' 
separates the parent topic from its lite topics");
+        }
+        InstanceVO instance = ownershipGuard.requireInstance(instanceId);
+        ownershipGuard.requireSupportedProvider(instance);
+        if (!liteTopicProvider.isSupported()) {
+            throw new BusinessException(LiteTopicProvider.NOT_IMPLEMENTED, 
LiteTopicProvider.UNSUPPORTED);
+        }
+        if (topic.getLiteTtlMinutes() == null) {
+            // The broker treats a missing or non-positive 
lite.topic.expiration as "never expire",
+            // so an omitted TTL is filled with the console default rather 
than forwarded as-is.
+            
topic.setLiteTtlMinutes(LiteTopicProvider.DEFAULT_LITE_TTL_MINUTES);
+        }
+    }
+
     public void deleteTopic(String name) {
         deleteTopic(null, name);
     }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicVO.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicVO.java
index 52a277120..a5dc28328 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicVO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/TopicVO.java
@@ -16,6 +16,7 @@
  */
 package org.apache.rocketmq.studio.instance.topic;
 
+import com.fasterxml.jackson.annotation.JsonInclude;
 import org.apache.rocketmq.studio.common.domain.BaseEntity;
 import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
 import org.apache.rocketmq.studio.common.domain.enums.TopicType;
@@ -37,4 +38,16 @@ public class TopicVO extends BaseEntity {
     private double tps;
     private int consumerGroupCount;
     private String remark;
+    /**
+     * Create-only TTL for Lite parent topics in minutes ({@code type=LITE}); 
never populated on
+     * read paths and never edited by the generic update path. Kept out of the 
JSON when unset so a
+     * list or detail response does not advertise a TTL the server never read 
back.
+     *
+     * <p>Unit note: this create-time field is in <em>minutes</em>, the unit 
of the broker's
+     * {@code lite.topic.expiration} attribute, while {@code POST 
/api/liteTopic/extendTTL} takes
+     * {@code newTTL} in <em>milliseconds</em> and the provider rounds it with
+     * {@code clamp(round(millis / 60000), 1, 43200)}.
+     */
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    private Integer liteTtlMinutes;
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/UpdateTopicDTO.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/UpdateTopicDTO.java
index 6a5da4e5a..ef949af42 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/topic/UpdateTopicDTO.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/topic/UpdateTopicDTO.java
@@ -37,6 +37,13 @@ public class UpdateTopicDTO {
     private TopicPerm perm;
     private String remark;
 
+    /**
+     * Bound only so an update that carries a Lite TTL is rejected with an 
actionable error instead
+     * of being dropped as an unknown property: the TTL is create-only and 
changes go through
+     * {@code POST /api/liteTopic/extendTTL}. {@code 
MetadataService#updateTopic} raises the 400.
+     */
+    private Integer liteTtlMinutes;
+
     public TopicVO toTopicVO() {
         TopicVO topic = new TopicVO();
         topic.setName(name);
@@ -51,6 +58,7 @@ public class UpdateTopicDTO {
         }
         topic.setPerm(perm);
         topic.setRemark(remark);
+        topic.setLiteTtlMinutes(liteTtlMinutes);
         return topic;
     }
 }
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/LiteTopicProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/LiteTopicProvider.java
index 87a976db4..d37cf869b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/LiteTopicProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/LiteTopicProvider.java
@@ -41,6 +41,21 @@ public interface LiteTopicProvider {
     String UNSUPPORTED = "LiteTopic is not supported by this provider";
     int NOT_IMPLEMENTED = 501;
 
+    /**
+     * Upper bound on a lite parent topic's TTL in minutes — mirrors
+     * {@code TopicAttributes.LITE_EXPIRATION_ATTRIBUTE}'s 30-day cap. The 
value must stay a
+     * literal: bean-validation annotations reference it and require a 
compile-time constant.
+     */
+    int MAX_LITE_TTL_MINUTES = 43_200;
+
+    /**
+     * TTL a new lite parent topic gets when the create request omits one, in 
minutes. The broker
+     * attribute itself defaults to {@code -1}, i.e. never expire, so a create 
that carries no TTL
+     * must not be forwarded as-is: it would leave an immortal parent topic 
behind. One day matches
+     * the console dialog's prefilled value.
+     */
+    int DEFAULT_LITE_TTL_MINUTES = 1_440;
+
     /**
      * Whether the active cluster exposes the LiteTopic admin surface. 
Detected by probing the
      * broker; a cluster running a RocketMQ build without LiteTopic answers 
the probe with an
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
index ca6016f58..51e63c093 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImpl.java
@@ -75,6 +75,7 @@ import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -321,7 +322,7 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
                 topicConfig.setWriteQueueNums(writeQueues);
                 topicConfig.setReadQueueNums(readQueues);
                 topicConfig.setPerm(toRocketMQPerm(effectivePerm));
-                applyTopicType(topicConfig, topic.getType());
+                applyTopicType(topicConfig, topic.getType(), 
topic.getLiteTtlMinutes());
 
                 boolean physicalExists = importing && verifyTopicImport(admin, 
brokerAddrs, topicConfig);
                 if (!physicalExists) {
@@ -433,7 +434,9 @@ public class RocketMQAdminClientImpl implements AdminClient 
{
                     }
                     // Broker attributes are merged separately and writes 
require delta keys.
                     topicConfig.setAttributes(Map.of());
-                    applyTopicType(topicConfig, topic.getType());
+                    // TTL is create-only: the update path passes none so a 
stored
+                    // lite.topic.expiration stays untouched (see the pin 
test).
+                    applyTopicType(topicConfig, topic.getType(), null);
                     pending.put(addr, topicConfig);
                 }
                 for (Map.Entry<String, TopicConfig> entry : 
pending.entrySet()) {
@@ -509,13 +512,25 @@ public class RocketMQAdminClientImpl implements 
AdminClient {
         return new TopicConfig(current);
     }
 
-    private void applyTopicType(TopicConfig topicConfig, TopicType topicType) {
-        if (topicType == null) {
+    /**
+     * Writes the create-time topic attributes as one change set. The update 
protocol only accepts
+     * attribute change entries ({@code +key=value}) which the broker merges 
into the stored
+     * attributes, so the message type and a Lite parent topic's TTL are sent 
together and only on
+     * create — updates pass no TTL and never touch {@code 
lite.topic.expiration}.
+     */
+    private void applyTopicType(TopicConfig topicConfig, TopicType topicType, 
Integer liteTtlMinutes) {
+        if (topicType == null && liteTtlMinutes == null) {
             return;
         }
-        topicConfig.setAttributes(Map.of(
-                "+" + TopicAttributes.TOPIC_MESSAGE_TYPE_ATTRIBUTE.getName(),
-                topicType.name()));
+        Map<String, String> attributes = new HashMap<>();
+        if (topicType != null) {
+            attributes.put("+" + 
TopicAttributes.TOPIC_MESSAGE_TYPE_ATTRIBUTE.getName(), topicType.name());
+        }
+        if (topicType == TopicType.LITE && liteTtlMinutes != null) {
+            attributes.put("+" + 
TopicAttributes.LITE_EXPIRATION_ATTRIBUTE.getName(),
+                    String.valueOf(liteTtlMinutes));
+        }
+        topicConfig.setAttributes(attributes);
     }
 
     @Override
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
index 478988355..82cf494be 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQLiteTopicProvider.java
@@ -97,9 +97,6 @@ public class RocketMQLiteTopicProvider implements 
LiteTopicProvider {
     /** Upper bound on per-lite-topic offset lookups for one session detail. */
     static final int MAX_SESSION_LITE_TOPIC_SCAN = 200;
 
-    /** Mirror of {@code TopicAttributes.LITE_EXPIRATION_ATTRIBUTE}'s upper 
bound (30 days). */
-    static final int MAX_LITE_TTL_MINUTES = (int) TimeUnit.DAYS.toMinutes(30);
-
     private final MqAdminExtFactory adminFactory;
     private final RocketMQProperties properties;
     private final RuntimeAdminClientResolver runtimeAdminClientResolver;
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
index 5276ecf2f..831c3796e 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/MetadataServiceTest.java
@@ -111,6 +111,9 @@ class MetadataServiceTest {
     @Mock
     private ResourceOwnershipGuard ownershipGuard;
 
+    @Mock
+    private org.apache.rocketmq.studio.provider.LiteTopicProvider 
liteTopicProvider;
+
     @InjectMocks
     private MetadataService metadataService;
 
@@ -621,6 +624,146 @@ class MetadataServiceTest {
         verifyNoInteractions(apacheProvider, operationAuditService);
     }
 
+    @Test
+    void createTopicShouldPassLiteTtlMinutesToProviderTest() {
+        lenient().when(liteTopicProvider.isSupported()).thenReturn(true);
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(1440);
+        when(apacheProvider.createTopic(any(), 
any(TopicVO.class))).thenReturn(input);
+
+        TopicVO result = metadataService.createTopic(input);
+
+        assertThat(result.getLiteTtlMinutes()).isEqualTo(1440);
+        verify(apacheProvider).createTopic("instance-a", input);
+    }
+
+    @Test
+    void createTopicRejectsLiteTtlMinutesForNonLiteTypesTest() {
+        TopicVO input = new TopicVO();
+        input.setName("orders");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.NORMAL);
+        input.setLiteTtlMinutes(1440);
+
+        assertThatThrownBy(() -> metadataService.createTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(400);
+                    assertThat(error.getMessage()).contains("liteTtlMinutes is 
only accepted when type is LITE");
+                });
+
+        verifyNoInteractions(apacheProvider);
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {0, -1, 43201})
+    void createTopicRejectsLiteTtlMinutesOutsideBoundsTest(int ttlMinutes) {
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(ttlMinutes);
+
+        assertThatThrownBy(() -> metadataService.createTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(400);
+                    assertThat(error.getMessage()).contains("liteTtlMinutes 
must be between 1 and 43200");
+                });
+
+        verifyNoInteractions(apacheProvider);
+    }
+
+    @Test
+    void createTopicRejectsDollarInLiteParentNameTest() {
+        TopicVO input = new TopicVO();
+        input.setName("chat$room");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(1440);
+
+        assertThatThrownBy(() -> metadataService.createTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(400);
+                    assertThat(error.getMessage()).contains("must not contain 
'$'");
+                });
+
+        verifyNoInteractions(apacheProvider);
+    }
+
+    @Test
+    void createTopicRejectsLiteWhenLiteTopicIsUnsupportedTest() {
+        when(liteTopicProvider.isSupported()).thenReturn(false);
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(1440);
+
+        assertThatThrownBy(() -> metadataService.createTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(501);
+                    assertThat(error.getMessage()).isEqualTo("LiteTopic is not 
supported by this provider");
+                });
+
+        verifyNoInteractions(apacheProvider);
+    }
+
+    @Test
+    void updateTopicRejectsLiteTtlMinutesTest() {
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(60);
+
+        assertThatThrownBy(() -> metadataService.updateTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(400);
+                    assertThat(error.getMessage()).contains("create-only");
+                });
+
+        verifyNoInteractions(apacheProvider);
+    }
+
+    @Test
+    void createTopicRejectsLiteParentOnCloudVendorInstanceTest() {
+        // A cloud OpenAPI has no lite.topic.expiration equivalent, so a LITE 
parent created there
+        // would keep the broker's -1 (never expire) default and stay outside 
the vendor's control
+        // plane. The gate is the same requireSupportedProvider the LiteTopic 
TTL write uses.
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("cloud-instance");
+        input.setType(TopicType.LITE);
+        input.setLiteTtlMinutes(1440);
+
+        assertThatThrownBy(() -> metadataService.createTopic(input))
+                .isInstanceOfSatisfying(BusinessException.class, error -> {
+                    assertThat(error.getCode()).isEqualTo(501);
+                    assertThat(error.getMessage()).contains("Cloud resources 
are not registered");
+                });
+
+        verifyNoInteractions(apacheProvider, cloudProvider, 
operationAuditService);
+    }
+
+    @Test
+    void createTopicDefaultsLiteTtlMinutesWhenOmittedTest() {
+        lenient().when(liteTopicProvider.isSupported()).thenReturn(true);
+        TopicVO input = new TopicVO();
+        input.setName("chat");
+        input.setInstanceId("instance-a");
+        input.setType(TopicType.LITE);
+        when(apacheProvider.createTopic(any(), 
any(TopicVO.class))).thenReturn(input);
+
+        metadataService.createTopic(input);
+
+        // Pinned to the literal so raising or lowering the default fails 
here: forwarding a LITE
+        // create with no TTL would store the broker's -1, i.e. a parent that 
never expires.
+        assertThat(input.getLiteTtlMinutes()).isEqualTo(1440);
+        verify(apacheProvider).createTopic("instance-a", input);
+    }
+
     @Test
     void apacheTopicWriteOperationsShouldNotDuplicateProviderAudit() {
         TopicVO topic = new TopicVO();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
index 6703bea0d..702b37884 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
@@ -24,6 +24,7 @@ import org.springframework.context.annotation.Import;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.rocketmq.studio.common.domain.enums.TopicPerm;
+import org.apache.rocketmq.studio.common.domain.enums.TopicType;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -271,6 +272,75 @@ class TopicControllerTest extends WebMvcAuthTestSupport {
         verifyNoInteractions(metadataService);
     }
 
+    @Test
+    void createTopicShouldPassLiteTtlMinutesTest() throws Exception {
+        TopicVO created = new TopicVO();
+        created.setName("chat");
+        created.setLiteTtlMinutes(1440);
+        
when(metadataService.createTopic(any(TopicVO.class))).thenReturn(created);
+        
when(instanceService.normalizeIdentifier("1")).thenReturn("open-source-local");
+
+        mockMvc.perform(post("/api/topics/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "instanceId": "1",
+                                  "name": "chat",
+                                  "type": "LITE",
+                                  "writeQueues": 8,
+                                  "readQueues": 8,
+                                  "liteTtlMinutes": 1440
+                                }
+                                """))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.liteTtlMinutes").value(1440));
+
+        ArgumentCaptor<TopicVO> captor = 
ArgumentCaptor.forClass(TopicVO.class);
+        verify(metadataService).createTopic(captor.capture());
+        assertThat(captor.getValue().getName()).isEqualTo("chat");
+        assertThat(captor.getValue().getLiteTtlMinutes()).isEqualTo(1440);
+    }
+
+    @Test
+    void createTopicShouldRejectLiteTtlMinutesOutsideBoundsTest() throws 
Exception {
+        mockMvc.perform(post("/api/topics/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "name": "chat",
+                                  "type": "LITE",
+                                  "writeQueues": 8,
+                                  "readQueues": 8,
+                                  "liteTtlMinutes": 43201
+                                }
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("liteTtlMinutes must be 
between 1 and 43200"));
+
+        verifyNoInteractions(metadataService);
+    }
+
+    @Test
+    void topicListResponseOmitsTheCreateOnlyLiteTtlTest() throws Exception {
+        TopicVO topic = new TopicVO();
+        topic.setName("orders");
+        topic.setType(TopicType.LITE);
+        topic.setWriteQueues(8);
+        topic.setReadQueues(8);
+        when(metadataService.listTopics(isNull(), isNull(), isNull(), 
isNull())).thenReturn(List.of(topic));
+
+        // A read path never computes the Lite TTL, so the key must not be 
advertised at all. The
+        // assertion is on the raw body because jsonPath(...).doesNotExist() 
also passes for an
+        // explicit JSON null, which is exactly the shape this guards against.
+        String body = mockMvc.perform(get("/api/topics"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data[0].name").value("orders"))
+                .andReturn().getResponse().getContentAsString();
+
+        assertThat(body).doesNotContain("liteTtlMinutes");
+    }
+
     @Test
     void topicWriteEndpointsShouldRejectNullRequestBody() throws Exception {
         mockMvc.perform(post("/api/topics/create")
@@ -316,6 +386,31 @@ class TopicControllerTest extends WebMvcAuthTestSupport {
         verifyNoInteractions(metadataService);
     }
 
+    @Test
+    void updateTopicBindsLiteTtlMinutesSoTheServiceCanRejectItTest() throws 
Exception {
+        TopicVO updated = new TopicVO();
+        updated.setName("chat");
+        
when(metadataService.updateTopic(any(TopicVO.class))).thenReturn(updated);
+
+        mockMvc.perform(post("/api/topics/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "instanceId": "1",
+                                  "name": "chat",
+                                  "type": "LITE",
+                                  "liteTtlMinutes": 60
+                                }
+                                """))
+                .andExpect(status().isOk());
+
+        // UpdateTopicDTO binds the create-only field on purpose: 
MetadataService turns it into an
+        // explicit 400 instead of the request being accepted with the TTL 
silently dropped.
+        ArgumentCaptor<TopicVO> captor = 
ArgumentCaptor.forClass(TopicVO.class);
+        verify(metadataService).updateTopic(captor.capture());
+        assertThat(captor.getValue().getLiteTtlMinutes()).isEqualTo(60);
+    }
+
     @Test
     void sendMessageShouldReturnResult() throws Exception {
         SendMessageDTO request = SendMessageDTO.builder()
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
index 8d1bdb8a4..cb5958992 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/service/ToolInstanceRoutingTest.java
@@ -88,7 +88,8 @@ class ToolInstanceRoutingTest {
         MetadataService metadata = new MetadataService(globalMetadata, 
globalAdmin, registry, targets,
                 mock(OperationAuditService.class), mock(MessageService.class),
                 mock(RuntimeAdminClientResolver.class),
-                
mock(org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.class));
+                
mock(org.apache.rocketmq.studio.instance.ResourceOwnershipGuard.class),
+                
mock(org.apache.rocketmq.studio.provider.LiteTopicProvider.class));
         TopicListToolHandler handler = new TopicListToolHandler(metadata);
         ToolDefinition definition = new ToolCatalog(new 
DefaultResourceLoader()).getDefinition(handler.name());
         ToolCatalog catalog = mock(ToolCatalog.class);
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
index 79476ca96..03a54214c 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQAdminClientImplTest.java
@@ -1147,6 +1147,110 @@ class RocketMQAdminClientImplTest {
                 
assertThat(config.getAttributes()).containsEntry("+message.type", 
TopicType.FIFO.name()));
     }
 
+    @Test
+    void updateTopicShouldKeepLiteTopicExpirationOutOfTheWriteDeltaTest() 
throws Exception {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new 
MybatisConfiguration(), ""), RmqTopic.class);
+        RmqTopic existing = new RmqTopic();
+        existing.setTopicType(TopicType.LITE.name());
+        existing.setWriteQueueNums(8);
+        existing.setReadQueueNums(8);
+        when(topicMapper.selectOne(any())).thenReturn(existing);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        // A LITE parent that already carries its TTL attribute: the generic 
update path re-asserts
+        // message.type only, so the stored lite.topic.expiration must stay 
out of the write delta.
+        TopicConfig stored = new TopicConfig("orders");
+        stored.setAttributes(Map.of("lite.topic.expiration", "1440"));
+        when(adminExt.examineTopicConfig(anyString(), 
eq("orders"))).thenReturn(stored);
+        doNothing().when(adminExt).createAndUpdateTopicConfig(anyString(), 
any(TopicConfig.class));
+
+        TopicVO topic = new TopicVO();
+        topic.setInstanceId("instance-a");
+        topic.setName("orders");
+        topic.setType(TopicType.LITE);
+        topic.setWriteQueues(16);
+
+        adminClient.updateTopic(topic);
+
+        ArgumentCaptor<TopicConfig> captor = 
ArgumentCaptor.forClass(TopicConfig.class);
+        verify(adminExt).createAndUpdateTopicConfig(anyString(), 
captor.capture());
+        assertThat(captor.getValue().getAttributes())
+                .containsEntry("+message.type", TopicType.LITE.name())
+                .containsOnlyKeys("+message.type");
+    }
+
+    @Test
+    void createTopicShouldWriteLiteTtlAttributeWithMessageTypeTest() throws 
Exception {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new 
MybatisConfiguration(), ""), RmqTopic.class);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        when(topicMapper.selectOne(any())).thenReturn(null);
+        doNothing().when(adminExt).createAndUpdateTopicConfig(anyString(), 
any(TopicConfig.class));
+
+        TopicVO topic = new TopicVO();
+        topic.setInstanceId("instance-a");
+        topic.setName("chat");
+        topic.setType(TopicType.LITE);
+        topic.setLiteTtlMinutes(1440);
+
+        adminClient.createTopic(topic);
+
+        ArgumentCaptor<TopicConfig> captor = 
ArgumentCaptor.forClass(TopicConfig.class);
+        verify(adminExt).createAndUpdateTopicConfig(anyString(), 
captor.capture());
+        assertThat(captor.getValue().getAttributes())
+                .containsEntry("+message.type", TopicType.LITE.name())
+                .containsEntry("+lite.topic.expiration", "1440");
+    }
+
+    @Test
+    void createTopicShouldIgnoreTtlMinutesForNonLiteTypesTest() throws 
Exception {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new 
MybatisConfiguration(), ""), RmqTopic.class);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        when(topicMapper.selectOne(any())).thenReturn(null);
+        doNothing().when(adminExt).createAndUpdateTopicConfig(anyString(), 
any(TopicConfig.class));
+
+        TopicVO topic = new TopicVO();
+        topic.setInstanceId("instance-a");
+        topic.setName("orders");
+        topic.setType(TopicType.NORMAL);
+        topic.setLiteTtlMinutes(1440);
+
+        adminClient.createTopic(topic);
+
+        ArgumentCaptor<TopicConfig> captor = 
ArgumentCaptor.forClass(TopicConfig.class);
+        verify(adminExt).createAndUpdateTopicConfig(anyString(), 
captor.capture());
+        assertThat(captor.getValue().getAttributes())
+                .containsEntry("+message.type", TopicType.NORMAL.name())
+                .doesNotContainKey("+lite.topic.expiration");
+    }
+
+    @Test
+    void updateTopicShouldIgnoreLiteTtlMinutesTest() throws Exception {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new 
MybatisConfiguration(), ""), RmqTopic.class);
+        RmqTopic existing = new RmqTopic();
+        existing.setTopicType(TopicType.LITE.name());
+        when(topicMapper.selectOne(any())).thenReturn(existing);
+        
when(adminExt.examineBrokerClusterInfo()).thenReturn(clusterInfoWithMaster());
+        TopicConfig stored = new TopicConfig("orders");
+        stored.setAttributes(Map.of("lite.topic.expiration", "1440"));
+        when(adminExt.examineTopicConfig(anyString(), 
eq("orders"))).thenReturn(stored);
+        doNothing().when(adminExt).createAndUpdateTopicConfig(anyString(), 
any(TopicConfig.class));
+
+        // A stale or malicious update payload carries a TTL: the generic 
update path must not
+        // write it — TTL is create-only and lives on the LiteTopic extension 
endpoint.
+        TopicVO topic = new TopicVO();
+        topic.setInstanceId("instance-a");
+        topic.setName("orders");
+        topic.setType(TopicType.LITE);
+        topic.setLiteTtlMinutes(60);
+
+        adminClient.updateTopic(topic);
+
+        ArgumentCaptor<TopicConfig> captor = 
ArgumentCaptor.forClass(TopicConfig.class);
+        verify(adminExt).createAndUpdateTopicConfig(anyString(), 
captor.capture());
+        assertThat(captor.getValue().getAttributes())
+                .containsOnlyKeys("+message.type")
+                .containsEntry("+message.type", TopicType.LITE.name());
+    }
+
     @Test
     void updateTopicWithoutTypePreservesExistingType() throws Exception {
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new 
MybatisConfiguration(), ""), RmqTopic.class);
@@ -2331,7 +2435,8 @@ class RocketMQAdminClientImplTest {
         
lenient().when(registry.byInstanceId("instance-a")).thenReturn(Optional.of(provider));
         return new MetadataService(mock(MetadataProvider.class), adminClient, 
registry,
                 mock(InstanceResolver.class), 
mock(OperationAuditService.class), mock(MessageService.class),
-                runtimeAdminClientResolver, ownershipGuard);
+                runtimeAdminClientResolver, ownershipGuard,
+                
mock(org.apache.rocketmq.studio.provider.LiteTopicProvider.class));
     }
 
     @Test
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index bfa08b729..02361644c 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -19,6 +19,8 @@ export interface Topic {
   tps: number;
   consumerGroupCount: number;
   remark: string;
+  /** Create-only TTL of a Lite parent topic in minutes (type=LITE); never 
sent on updates. */
+  liteTtlMinutes?: number;
   gmtCreate: string;
   gmtModified: string;
 }
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 3ef910a1e..b00b05ac9 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -3099,6 +3099,18 @@ const translations: Record<string, Record<Lang, string>> 
= {
     zh: '每个 Broker 节点写 {write} / 读 {read} 个队列;读写队列数一般情况下需保持一致',
     en: '{write} write / {read} read queues per Broker node; keep them equal 
in general',
   },
+  'topic.liteTtl': { zh: 'TTL(分钟)', en: 'TTL (minutes)' },
+  'topic.liteTtlUnit': { zh: '分钟', en: 'min' },
+  'topic.liteTtlRequired': { zh: '请输入 TTL', en: 'Please enter the TTL' },
+  'topic.liteTtlExtra': {
+    zh:
+      '子 LiteTopic 超过 TTL 未发送新消息即自动过期回收;范围 1-43200(30 天)。创建后在 LiteTopic ' +
+      '页「延长 TTL」调整,那里以毫秒为单位(60000 毫秒 = 1 分钟)',
+    en:
+      'Lite topics under this parent expire and are reclaimed after this many 
minutes without new ' +
+      'messages; range 1-43200 (30 days). Adjust it later from the LiteTopic 
page, where Extend TTL ' +
+      'takes milliseconds (60000 ms = 1 minute)',
+  },
   'topic.remarkPlaceholder': {
     zh: '可选,描述 Topic 用途',
     en: 'Optional, describe the topic purpose',
@@ -3970,6 +3982,7 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'liteTopic.status': { zh: '状态', en: 'Status' },
   'liteTopic.actions': { zh: '操作', en: 'Actions' },
   'liteTopic.extendTtl': { zh: '延长 TTL', en: 'Extend TTL' },
+  'liteTopic.createParent': { zh: '创建 Lite 父主题', en: 'Create Lite parent' },
   'liteTopic.viewSessions': { zh: '查看会话', en: 'View Sessions' },
   'liteTopic.sessionDetail': { zh: '会话详情', en: 'Session Detail' },
   'liteTopic.sessionId': { zh: '会话 ID', en: 'Session ID' },
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 3d3f9febd..daf494025 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -125,7 +125,9 @@ const healthyBrokerRoute: BrokerRoute = {
   replicaCount: 1,
 };
 
-const renderWithProviders = (initialEntry = '/instance/topic') =>
+const renderWithProviders = (
+  initialEntry: string | { pathname: string; state?: unknown } = 
'/instance/topic',
+) =>
   render(
     <App>
       <LangProvider>
@@ -376,6 +378,81 @@ describe('TopicPage', () => {
     expect(within(getTableBody()).queryByText('create 
response')).not.toBeInTheDocument();
   });
 
+  it('sends the Lite TTL when creating a Lite parent topic', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders('/instance/instance-proxy-1/topic');
+
+    await screen.findByText('topic-01');
+    await user.click(screen.getByRole('button', { name: /创建 Topic/ }));
+    const dialog = await screen.findByRole('dialog', { name: /创建 Topic/ });
+    await user.type(within(dialog).getByLabelText('Topic 名称'), 'chat');
+    await user.click(within(dialog).getByText('LiteTopic'));
+
+    // The TTL input appears only for LITE creates and is prefilled with one 
day (1440 minutes).
+    const ttl = within(dialog).getByLabelText('TTL(分钟)') as HTMLInputElement;
+    expect(ttl.value).toBe('1440');
+
+    await user.click(within(dialog).getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(1));
+    expect(topicServiceMocks.createTopic).toHaveBeenCalledWith(
+      expect.objectContaining({ name: 'chat', type: 'LITE', liteTtlMinutes: 
1440 }),
+    );
+  });
+
+  it('leaves the Lite TTL out of non-Lite creates', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders('/instance/instance-proxy-1/topic');
+
+    await screen.findByText('topic-01');
+    await user.click(screen.getByRole('button', { name: /创建 Topic/ }));
+    const dialog = await screen.findByRole('dialog', { name: /创建 Topic/ });
+    expect(within(dialog).queryByLabelText('TTL(分钟)')).not.toBeInTheDocument();
+    await user.type(within(dialog).getByLabelText('Topic 名称'), 'orders');
+    await user.click(within(dialog).getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(1));
+    
expect(topicServiceMocks.createTopic.mock.calls[0][0]).not.toHaveProperty('liteTtlMinutes');
+  });
+
+  it('never sends the Lite TTL on topic updates', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    const liteTopic = { ...buildTopics(1)[0], type: 'LITE' };
+    mockTopicsList([liteTopic]);
+    renderWithProviders('/instance/instance-proxy-1/topic');
+
+    await screen.findByText('topic-01');
+    const row = screen.getByText('topic-01').closest('tr') as HTMLElement;
+    await user.click(within(row).getByRole('button', { name: /配置/ }));
+    const dialog = await screen.findByRole('dialog', { name: /编辑 Topic/ });
+
+    // TTL is create-only: the edit dialog has no TTL input even for a Lite 
parent topic.
+    expect(within(dialog).queryByLabelText('TTL(分钟)')).not.toBeInTheDocument();
+
+    await user.click(within(dialog).getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.updateTopic).toHaveBeenCalledTimes(1));
+    
expect(topicServiceMocks.updateTopic.mock.calls[0][0]).not.toHaveProperty('liteTtlMinutes');
+  });
+
+  it('opens the create dialog with LITE preselected for the LiteTopic page 
action', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders({
+      pathname: '/instance/instance-proxy-1/topic',
+      state: { createType: 'LITE' },
+    });
+
+    const dialog = await screen.findByRole('dialog', { name: /创建 Topic/ });
+    expect(within(dialog).getByLabelText('TTL(分钟)')).toBeInTheDocument();
+    await user.type(within(dialog).getByLabelText('Topic 名称'), 'chat');
+    await user.click(within(dialog).getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(1));
+    expect(topicServiceMocks.createTopic).toHaveBeenCalledWith(
+      expect.objectContaining({ name: 'chat', type: 'LITE', liteTtlMinutes: 
1440 }),
+    );
+  });
+
   it('updates topic config through the update service and reloads the page', 
async () => {
     topicServiceMocks.updateTopic.mockResolvedValue({
       ...buildTopics(1)[0],
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index ad75998a3..712bd124f 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -16,7 +16,7 @@
  */
 
 import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate } from 'react-router-dom';
 import {
   Alert,
   Table,
@@ -294,6 +294,7 @@ const TopicPageContent = ({
 }: TopicPageContentProps) => {
   const { t } = useLang();
   const navigate = useNavigate();
+  const location = useLocation();
   const isCloudInstance =
     selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor === 
'TENCENT';
   const canSendTestMessage = (topic: Topic) => !isCloudInstance || topic.type 
=== 'NORMAL';
@@ -338,6 +339,21 @@ const TopicPageContent = ({
   // 队列数提示要跟着输入值走(用户约定 2026-10-16:写死的「8 个队列」不随值变化是错的)
   const createWriteQueues = Form.useWatch('writeQueues', form);
   const createReadQueues = Form.useWatch('readQueues', form);
+
+  // The LiteTopic page's "Create Lite parent" action lands here with LITE 
preselected and
+  // opens this same create dialog; the state is cleared so refresh/back 
cannot reopen it.
+  // The handoff is consumed on a microtask (same shape as useDraftHandoff) so 
the dialog opens
+  // outside the effect pass instead of stacking renders on it.
+  useEffect(() => {
+    const state = location.state as { createType?: string } | null;
+    if (state?.createType !== 'LITE') return;
+    void Promise.resolve().then(() => {
+      form.setFieldsValue({ type: 'LITE', liteTtlMinutes: 1440 });
+      setEditingTopic(null);
+      setModalOpen(true);
+      navigate('.', { replace: true, state: {} });
+    });
+  }, [location.state, form, navigate]);
   const [sendModalOpen, setSendModalOpen] = useState(false);
   const [sendTopic, setSendTopic] = useState<Topic | null>(null);
   const [sending, setSending] = useState(false);
@@ -1001,17 +1017,20 @@ const TopicPageContent = ({
     setCreating(true);
     try {
       const values = await form.validateFields();
+      // The Lite TTL is create-only: updates never carry it and non-Lite 
creates leave it out.
+      const { liteTtlMinutes, ...rest } = values;
       if (editingTopic) {
         const updated = await updateTopic({
-          ...values,
+          ...rest,
           instanceId: selectedInstanceId,
         });
         await reloadTopicPage();
         message.success(`Topic「${updated.name}」更新成功`);
       } else {
         const created = await createTopic({
-          ...values,
+          ...rest,
           instanceId: selectedInstanceId,
+          ...(values.type === 'LITE' ? { liteTtlMinutes } : {}),
         });
         await reloadTopicPage();
         message.success(`Topic「${created.name}」创建成功`);
@@ -1523,6 +1542,7 @@ const TopicPageContent = ({
             readQueues: 8,
             perm: 'RW',
             type: 'NORMAL',
+            liteTtlMinutes: 1440,
           }}
           style={{ marginTop: 16 }}
         >
@@ -1567,6 +1587,23 @@ const TopicPageContent = ({
             />
           </Form.Item>
 
+          {/* A Lite parent topic's TTL is written at create time and never 
edited afterwards. */}
+          {createTopicType === 'LITE' && !editingTopic && (
+            <Form.Item
+              label={t('topic.liteTtl')}
+              name="liteTtlMinutes"
+              rules={[{ required: true, message: t('topic.liteTtlRequired') }]}
+              extra={t('topic.liteTtlExtra')}
+            >
+              <InputNumber
+                min={1}
+                max={43200}
+                style={{ width: '100%' }}
+                addonAfter={t('topic.liteTtlUnit')}
+              />
+            </Form.Item>
+          )}
+
           {!isCloudInstance && (
             <>
               <Row gutter={16}>
diff --git a/web/src/pages/studio/LiteTopic.tsx 
b/web/src/pages/studio/LiteTopic.tsx
index be301bb67..67ddd66b4 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -46,7 +46,9 @@ import {
   Gauge,
   Info,
   DownloadSimple,
+  Plus,
 } from '@phosphor-icons/react';
+import { useNavigate } from 'react-router-dom';
 import PageHeader from '../../components/PageHeader';
 import InfoBanner from '../../components/InfoBanner';
 import { useLang } from '../../i18n/LangContext';
@@ -140,6 +142,7 @@ const collectNamespaces = (items: LiteTopicItem[]): 
string[] => {
 const LiteTopicPage: React.FC = () => {
   const { t } = useLang();
   const { message } = App.useApp();
+  const navigate = useNavigate();
   const messageRef = useRef(message);
   const translationRef = useRef(t);
 
@@ -876,6 +879,14 @@ const LiteTopicPage: React.FC = () => {
         title={t('liteTopic.title')}
         extra={
           <Space>
+            <Button
+              type="primary"
+              icon={<Plus size={14} />}
+              size="small"
+              onClick={() => navigate('/instance/topic', { state: { 
createType: 'LITE' } })}
+            >
+              {t('liteTopic.createParent')}
+            </Button>
             <Button
               icon={<DownloadSimple size={14} />}
               size="small"
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx 
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index e645bcfa8..77d4cf05b 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -18,6 +18,7 @@
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { act, render, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
@@ -75,7 +76,12 @@ const renderPage = () =>
   render(
     <App>
       <LangProvider>
-        <LiteTopic />
+        <MemoryRouter initialEntries={['/studio/lite-topic']}>
+          <Routes>
+            <Route path="/studio/lite-topic" element={<LiteTopic />} />
+            <Route path="/instance/topic" 
element={<div>topic-create-page</div>} />
+          </Routes>
+        </MemoryRouter>
       </LangProvider>
     </App>,
   );
@@ -358,6 +364,16 @@ describe('LiteTopic Page', () => {
     );
   });
 
+  it('routes to the generic topic page to create a Lite parent', async () => {
+    const user = userEvent.setup();
+    renderPage();
+
+    await screen.findByText('order-*');
+    await user.click(screen.getByRole('button', { name: /创建 Lite 父主题/ }));
+
+    expect(await screen.findByText('topic-create-page')).toBeInTheDocument();
+  });
+
   it('returns to the first page when the local TTL filter changes', async () 
=> {
     apiMocks.queryLiteTopicList.mockResolvedValue([
       ...Array.from({ length: 11 }, (_, index) => ({

Reply via email to