网络请求通过 Retrofit 返回 Single,由基类 CompositeDisposable 托管,
+ * 页面销毁自动取消订阅。
+ */
+public class LoginViewModel extends BaseViewModel {
+
+ private final AuthRepository repository = new AuthRepository();
+
+ private final MutableLiveData 所有网络请求均走 Retrofit + RxJava3,由基类 CompositeDisposable 托管,
+ * Activity 销毁时自动取消,替代原先的 {@code new Thread} + {@code runOnUiThread} 写法。
+ */
public class MainViewModel extends BaseViewModel {
+
+ private final AuthRepository repository = new AuthRepository();
+
+ /** 刷新单飞标记,避免并发重复刷新令牌。 */
+ private final AtomicBoolean refreshInFlight = new AtomicBoolean(false);
+
+ /** 可用的 accessToken,用于建立 WebSocket。 */
+ private final MutableLiveData 校验本地令牌:已登录直接进入 {@link MainActivity},否则进入 {@link LoginActivity}。
+ */
+public class SplashActivity extends BaseMvvmActivity 登录与刷新接口本身不需要(也不能)携带旧令牌,因此在此跳过。
+ */
+public class AuthInterceptor implements Interceptor {
+
+ @NonNull
+ @Override
+ public Response intercept(@NonNull Chain chain) throws IOException {
+ Request original = chain.request();
+ String path = original.url().encodedPath();
+
+ if (path.contains("/api/auth/login") || path.contains("/api/auth/refresh")) {
+ return chain.proceed(original);
+ }
+
+ String token = TokenStore.get().getAccessToken();
+ if (token == null || token.isEmpty()) {
+ return chain.proceed(original);
+ }
+
+ Request authorized = original.newBuilder()
+ .header("Authorization", "Bearer " + token)
+ .build();
+ return chain.proceed(authorized);
+ }
+}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/network/AuthRepository.java b/WebRTCController/app/src/main/java/com/ttstd/controller/network/AuthRepository.java
new file mode 100644
index 0000000..e1af40d
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/network/AuthRepository.java
@@ -0,0 +1,84 @@
+package com.ttstd.controller.network;
+
+import com.ttstd.controller.network.model.BindingsResponse;
+import com.ttstd.controller.network.model.LoginRequest;
+import com.ttstd.controller.network.model.RefreshRequest;
+import com.ttstd.controller.network.model.TokenResponse;
+import com.ttstd.controller.network.model.VerifyResponse;
+import com.ttstd.controller.utils.TokenStore;
+
+import io.reactivex.rxjava3.core.Single;
+import io.reactivex.rxjava3.schedulers.Schedulers;
+
+/**
+ * 用户端认证 / 会话仓库。
+ *
+ * 对外统一返回 {@code Single} 冷流并已切到 IO 线程,由 ViewModel 的
+ * CompositeDisposable 订阅,随生命周期自动取消。
+ */
+public class AuthRepository {
+
+ private final AuthApi authApi;
+ private final ClientApi clientApi;
+
+ public AuthRepository() {
+ this.authApi = RetrofitClient.authApi();
+ this.clientApi = RetrofitClient.clientApi();
+ }
+
+ /**
+ * 用户名密码登录,成功后自动持久化令牌。
+ */
+ public Single 令牌由 {@link AuthInterceptor} 统一注入 Authorization 头,此处无需声明。
+ */
+public interface ClientApi {
+
+ /** 校验当前令牌是否有效,用于启动页判断登录态。 */
+ @GET("api/client/verify")
+ Single 统一注入 {@link AuthInterceptor} 携带 Bearer 令牌,并通过 RxJava3 适配器
+ * 返回 {@code Single} 冷流,便于在 ViewModel 中配合 CompositeDisposable 管理生命周期。
+ */
+public final class RetrofitClient {
+ /** 网络超时(秒)。 */
+ public static final long TIMEOUT_SECONDS = 15L;
+
+ private static volatile Retrofit retrofit;
+ private static volatile AuthApi authApi;
+ private static volatile ClientApi clientApi;
+
+ private RetrofitClient() {
+ }
+
+ private static Retrofit retrofit() {
+ if (retrofit == null) {
+ synchronized (RetrofitClient.class) {
+ if (retrofit == null) {
+ retrofit = build();
+ }
+ }
+ }
+ return retrofit;
+ }
+
+ private static Retrofit build() {
+ HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
+ logging.setLevel(HttpLoggingInterceptor.Level.BODY);
+
+ OkHttpClient okHttpClient = new OkHttpClient.Builder()
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .addInterceptor(new AuthInterceptor())
+ .addInterceptor(logging)
+ .build();
+
+ return new Retrofit.Builder()
+ .baseUrl(BuildConfig.API_BASE)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .addCallAdapterFactory(RxJava3CallAdapterFactory.create())
+ .build();
+ }
+
+ public static AuthApi authApi() {
+ if (authApi == null) {
+ synchronized (RetrofitClient.class) {
+ if (authApi == null) {
+ authApi = retrofit().create(AuthApi.class);
+ }
+ }
+ }
+ return authApi;
+ }
+
+ public static ClientApi clientApi() {
+ if (clientApi == null) {
+ synchronized (RetrofitClient.class) {
+ if (clientApi == null) {
+ clientApi = retrofit().create(ClientApi.class);
+ }
+ }
+ }
+ return clientApi;
+ }
+}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingItem.java b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingItem.java
new file mode 100644
index 0000000..6e423b8
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingItem.java
@@ -0,0 +1,60 @@
+package com.ttstd.controller.network.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/** 绑定关系条目,对应服务端 {@code ClientController#bindingView}。 */
+public class BindingItem {
+
+ @SerializedName("bindingId")
+ private String bindingId;
+
+ /** 被控端设备唯一标识,即连接时的目标设备 ID。 */
+ @SerializedName("deviceUid")
+ private String deviceUid;
+
+ @SerializedName("userId")
+ private String userId;
+
+ /** OWNER / MEMBER。 */
+ @SerializedName("role")
+ private String role;
+
+ @SerializedName("alias")
+ private String alias;
+
+ /** ACTIVE / REVOKED。 */
+ @SerializedName("status")
+ private String status;
+
+ public String getBindingId() {
+ return bindingId;
+ }
+
+ public String getDeviceUid() {
+ return deviceUid;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public String getAlias() {
+ return alias;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ /** 下拉展示文案:有别名时优先展示别名。 */
+ public String displayName() {
+ if (alias != null && !alias.trim().isEmpty()) {
+ return alias + " (" + deviceUid + ")";
+ }
+ return deviceUid;
+ }
+}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingsResponse.java b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingsResponse.java
new file mode 100644
index 0000000..64464fd
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/BindingsResponse.java
@@ -0,0 +1,17 @@
+package com.ttstd.controller.network.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.Collections;
+import java.util.List;
+
+/** 已绑定设备列表响应,对应 {@code GET /api/client/bindings}。 */
+public class BindingsResponse {
+
+ @SerializedName("bindings")
+ private List 设备令牌场景 refreshToken 为 null;主控端(用户令牌)两者均会下发。
+ */
+public class TokenResponse {
+
+ @SerializedName("accessToken")
+ private String accessToken;
+
+ @SerializedName("refreshToken")
+ private String refreshToken;
+
+ /** access 令牌有效期(秒)。 */
+ @SerializedName("expiresIn")
+ private long expiresIn;
+
+ @SerializedName("sessionId")
+ private String sessionId;
+
+ /** 用户 ID。 */
+ @SerializedName("principalId")
+ private String principalId;
+
+ /** 展示名(一般为用户名)。 */
+ @SerializedName("displayName")
+ private String displayName;
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public String getRefreshToken() {
+ return refreshToken;
+ }
+
+ public long getExpiresIn() {
+ return expiresIn;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public String getPrincipalId() {
+ return principalId;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/VerifyResponse.java b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/VerifyResponse.java
new file mode 100644
index 0000000..9e4239f
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/network/model/VerifyResponse.java
@@ -0,0 +1,59 @@
+package com.ttstd.controller.network.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/** 令牌校验响应,对应服务端 {@code GET /api/client/verify}。 */
+public class VerifyResponse {
+
+ @SerializedName("valid")
+ private boolean valid;
+
+ /** USER / DEVICE。 */
+ @SerializedName("principalType")
+ private String principalType;
+
+ @SerializedName("principalId")
+ private String principalId;
+
+ @SerializedName("displayName")
+ private String displayName;
+
+ /** 过期时间(秒级时间戳)。 */
+ @SerializedName("expiresAt")
+ private long expiresAt;
+
+ /** 剩余有效秒数。 */
+ @SerializedName("remainingSeconds")
+ private long remainingSeconds;
+
+ @SerializedName("message")
+ private String message;
+
+ public boolean isValid() {
+ return valid;
+ }
+
+ public String getPrincipalType() {
+ return principalType;
+ }
+
+ public String getPrincipalId() {
+ return principalId;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public long getExpiresAt() {
+ return expiresAt;
+ }
+
+ public long getRemainingSeconds() {
+ return remainingSeconds;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java b/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java
deleted file mode 100644
index da6aba2..0000000
--- a/WebRTCController/app/src/main/java/com/ttstd/controller/signaling/ApiClient.java
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.ttstd.controller.signaling;
-
-import android.util.Log;
-
-import com.google.gson.Gson;
-import com.google.gson.JsonObject;
-import com.ttstd.controller.BuildConfig;
-
-import java.util.concurrent.TimeUnit;
-
-import okhttp3.MediaType;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
-import okhttp3.ResponseBody;
-
-/**
- * 主控端 HTTP 客户端:对接安全信令服务器的账号体系与自助接口。
- *
- * - login(username,password) → accessToken + refreshToken(一次性,ses_ 前缀);
- * - refresh(refreshToken) → 新 accessToken(服务端可能轮换 refreshToken);
- * - verify() → 校验 accessToken 是否仍有效;
- * - bindings() → 本机可连接的被控端列表(仅已绑定设备);
- * - turnCredentials() → TURN 短期凭证(服务端开启时返回 iceServers)。
- *
- * 刷新单飞锁由调用方(MainActivity)保证并发只触发一次。
- */
-public final class ApiClient {
-
- private static final String TAG = "ControllerApiClient";
- private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
-
- private final OkHttpClient http;
- private final Gson gson = new Gson();
-
- public ApiClient() {
- this.http = new OkHttpClient.Builder()
- .connectTimeout(15, TimeUnit.SECONDS)
- .readTimeout(15, TimeUnit.SECONDS)
- .build();
- }
-
- public static String apiBase() {
- return BuildConfig.API_BASE;
- }
-
- public JsonObject login(String username, String password) {
- JsonObject body = new JsonObject();
- body.addProperty("username", username);
- body.addProperty("password", password);
- return post("/api/auth/login", body, false, null);
- }
-
- public JsonObject register(String username, String password) {
- JsonObject body = new JsonObject();
- body.addProperty("username", username);
- body.addProperty("password", password);
- return post("/api/auth/register", body, false, null);
- }
-
- /** 刷新令牌。需带 refreshToken(作为 body)。 */
- public JsonObject refresh(String refreshToken) {
- JsonObject body = new JsonObject();
- body.addProperty("refreshToken", refreshToken);
- return post("/api/auth/refresh", body, false, null);
- }
-
- public JsonObject verify(String accessToken) {
- return get("/api/client/verify", accessToken);
- }
-
- public JsonObject bindings(String accessToken) {
- return get("/api/client/bindings", accessToken);
- }
-
- public JsonObject turnCredentials(String accessToken) {
- return get("/api/client/turn-credentials", accessToken);
- }
-
- private JsonObject post(String path, JsonObject body, boolean auth, String token) {
- Request.Builder b = new Request.Builder().url(apiBase() + path)
- .post(RequestBody.create(body.toString(), JSON));
- if (auth && token != null) b.addHeader("Authorization", "Bearer " + token);
- return call(b.build(), path);
- }
-
- private JsonObject get(String path, String token) {
- Request.Builder b = new Request.Builder().url(apiBase() + path).get();
- if (token != null) b.addHeader("Authorization", "Bearer " + token);
- return call(b.build(), path);
- }
-
- private JsonObject call(Request request, String step) {
- try (Response resp = http.newCall(request).execute()) {
- ResponseBody body = resp.body();
- String text = body != null ? body.string() : "";
- if (!resp.isSuccessful()) {
- throw new ApiException("HTTP " + resp.code() + " " + text, resp.code());
- }
- return gson.fromJson(text, JsonObject.class);
- } catch (Exception e) {
- Log.e(TAG, step + " 请求失败", e);
- if (e instanceof ApiException) throw (ApiException) e;
- throw new ApiException(e.getMessage(), -1);
- }
- }
-
- /** API 调用异常,携带 HTTP 状态码(401 表示令牌失效)。 */
- public static class ApiException extends RuntimeException {
- public final int httpCode;
- public ApiException(String msg, int code) {
- super(msg);
- this.httpCode = code;
- }
- }
-}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java b/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java
index 422fae6..4fb5240 100644
--- a/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/utils/TokenStore.java
@@ -24,12 +24,34 @@ public final class TokenStore {
private static final String KEY_REFRESH = "refresh_token";
private static final String KEY_USERNAME = "username";
+ private static volatile TokenStore instance;
+
private final SharedPreferences sp;
public TokenStore(Context context) {
this.sp = create(context);
}
+ /**
+ * 在 {@code BaseApplication#onCreate} 中初始化一次,供拦截器与仓库层全局访问。
+ */
+ public static void init(Context context) {
+ if (instance == null) {
+ synchronized (TokenStore.class) {
+ if (instance == null) {
+ instance = new TokenStore(context.getApplicationContext());
+ }
+ }
+ }
+ }
+
+ public static TokenStore get() {
+ if (instance == null) {
+ throw new IllegalStateException("TokenStore 未初始化,请先在 Application 中调用 TokenStore.init()");
+ }
+ return instance;
+ }
+
private static SharedPreferences create(Context context) {
try {
MasterKey masterKey = new MasterKey.Builder(context)
diff --git a/WebRTCController/app/src/main/res/layout/activity_login.xml b/WebRTCController/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..f155cf5
--- /dev/null
+++ b/WebRTCController/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,99 @@
+
+ 支持两种凭据,满足其一即可通过:
- * 过滤器顺序在 {@link BearerAuthFilter} 之后,以便读取其解析出的主体。
+ * 与主控端用户体系完全独立,使用 {@link AdminService} 专用令牌(前缀 admt_)。
+ * 仅对 {@code /api/admin/**} 生效;免鉴权路径(如登录、健康检查)统一由
+ * {@link PublicEndpoints} 管理。
*/
@Component
@Order(2)
public class AdminAuthFilter extends OncePerRequestFilter {
- @Value("${admin.token:webrtc-admin-token}")
- private String adminToken;
+ private final AdminService adminService;
+
+ public AdminAuthFilter(AdminService adminService) {
+ this.adminService = adminService;
+ }
+
+ private static final String PREFIX = "/api/admin/";
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getServletPath();
+ if (path == null || !path.startsWith(PREFIX)) {
+ return true;
+ }
+ // 免鉴权路径(由 PublicEndpoints 统一管理,例如登录接口)放行
+ return PublicEndpoints.isPublic(request);
+ }
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
- String uri = request.getRequestURI();
- if (!uri.startsWith("/api/admin")) {
- filterChain.doFilter(request, response);
+ String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (header == null || !header.startsWith("Bearer ")) {
+ writeUnauthorized(response, "缺少管理员令牌");
return;
}
- if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
- filterChain.doFilter(request, response);
- return;
- }
-
- if (hasValidAdminToken(request) || hasAdminPrincipal(request)) {
- filterChain.doFilter(request, response);
+ String token = header.substring("Bearer ".length()).trim();
+ if (token.isEmpty()) {
+ writeUnauthorized(response, "管理员令牌为空");
return;
}
+ try {
+ Map 所有免登录/免令牌的公开接口集中在此登记,避免各过滤器各自硬编码放行规则
+ * 导致遗漏或冲突(例如登录接口被误拦截返回 401)。
+ *
+ * 匹配规则:
+ * 旧版本曾存在独立的 {@code password_history} 表(含外键
+ * {@code fk_pwdhist_user} 指向 {@code app_user.user_id})。当前代码已将其合并为
+ * {@code app_user.password_history} 字段,不再使用该表,但其外键会阻止 Hibernate
+ * 在 {@code ddl-auto: update} 下对 {@code app_user.user_id} 列做必要迁移,导致启动失败。
+ *
+ * 本组件在 JPA 执行 DDL 之前(仅依赖 DataSource,不依赖任何 JPA 组件)幂等删除该废弃表。
+ * 可通过 {@code security.schema.cleanup-enabled=false} 关闭。
+ */
+@Component
+@Order(0)
+@ConditionalOnProperty(name = "security.schema.cleanup-enabled", havingValue = "true", matchIfMissing = true)
+public class SchemaCleanup {
+
+ private static final Logger logger = LoggerFactory.getLogger(SchemaCleanup.class);
+
+ private final DataSource dataSource;
+
+ public SchemaCleanup(DataSource dataSource) {
+ this.dataSource = dataSource;
+ }
+
+ @PostConstruct
+ public void cleanup() {
+ try (Connection conn = dataSource.getConnection();
+ Statement stmt = conn.createStatement()) {
+ // 仅清理确已废弃、且代码无任何实体/Repository 引用的遗留表
+ stmt.execute("DROP TABLE IF EXISTS password_history");
+ logger.info("已清理废弃表 password_history(若存在)");
+ } catch (Exception e) {
+ // 清理失败不应阻断启动;记录告警,留给人工处理
+ logger.warn("清理废弃表 password_history 失败(可忽略,手动 DROP 亦可): {}", e.toString());
+ }
+ }
+}
diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java
index 9958de7..817efee 100644
--- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java
+++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/config/SecurityBeansConfig.java
@@ -2,8 +2,11 @@ package com.ttstd.signaling.config;
import com.ttstd.signaling.security.SecurityProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 启用安全相关配置属性绑定与定时任务。
@@ -12,4 +15,13 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableConfigurationProperties(SecurityProperties.class)
@EnableScheduling
public class SecurityBeansConfig {
+
+ /**
+ * 全局密码编码器 Bean。供 AdminService(后台管理员)注入使用,
+ * 与 AccountService 内部使用的 BCrypt(strength=12) 保持一致。
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder(12);
+ }
}
diff --git a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java
index 39e309b..fab086c 100644
--- a/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java
+++ b/WebRTCSignalServer/src/main/java/com/ttstd/signaling/controller/AdminController.java
@@ -4,15 +4,19 @@ import com.ttstd.signaling.manager.ConnectionRequestManager;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.manager.SignalMetrics;
import com.ttstd.signaling.model.AbuseReport;
+import com.ttstd.signaling.model.AdminAccount;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.DeviceInfo;
import com.ttstd.signaling.model.UserAccount;
import com.ttstd.signaling.service.AbuseReportService;
import com.ttstd.signaling.service.AccountService;
+import com.ttstd.signaling.service.AdminService;
import com.ttstd.signaling.service.AuditService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
+import org.springframework.data.domain.Page;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -30,7 +34,7 @@ import java.util.stream.Collectors;
/**
* 后台管理 REST 接口:为 WebRTCSignalServerWeb 提供服务器运行态查询。
- * 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 统一鉴权。
+ * 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 校验管理员令牌。
*/
@RestController
@RequestMapping("/api/admin")
@@ -44,6 +48,7 @@ public class AdminController {
private final AuditService auditService;
private final BindingService bindingService;
private final AbuseReportService abuseReportService;
+ private final AdminService adminService;
private final long startTime = System.currentTimeMillis();
public AdminController(SessionManager sessionManager,
@@ -53,7 +58,8 @@ public class AdminController {
DeviceIdentityService deviceIdentityService,
AuditService auditService,
BindingService bindingService,
- AbuseReportService abuseReportService) {
+ AbuseReportService abuseReportService,
+ AdminService adminService) {
this.sessionManager = sessionManager;
this.connectionRequestManager = connectionRequestManager;
this.metrics = metrics;
@@ -62,6 +68,7 @@ public class AdminController {
this.auditService = auditService;
this.bindingService = bindingService;
this.abuseReportService = abuseReportService;
+ this.adminService = adminService;
}
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
@@ -129,12 +136,158 @@ public class AdminController {
return data;
}
+ // ==================== 管理员登录 ====================
+
+ /**
+ * 后台管理员登录,签发独立的管理员令牌(前缀 admt_)。
+ * 该接口免鉴权;请求体:{@code {"username": "admin", "password": "***"}}。
+ */
+ @PostMapping("/login")
+ public Map> bindings = new MutableLiveData<>();
+
+ public LiveData
> getBindings() {
+ return bindings;
+ }
+
+ /**
+ * 确保 accessToken 有效:先校验,失效则自动刷新,结果通过
+ * {@link #getAccessToken()} 回调到主线程。
+ */
+ public void prepareAccessToken() {
+ execute(
+ repository.checkLoginState(),
+ valid -> {
+ if (Boolean.TRUE.equals(valid)) {
+ accessToken.setValue(TokenStore.get().getAccessToken());
+ } else {
+ authFailed.setValue("登录已失效,请重新登录");
+ }
+ },
+ error -> authFailed.setValue("认证失败:" + error.getMessage()));
+ }
+
+ /**
+ * WebSocket 收到 4001(令牌过期):单飞刷新后通知重连。
+ */
+ public void refreshTokenAndReconnect() {
+ if (!refreshInFlight.compareAndSet(false, true)) {
+ return;
+ }
+ execute(
+ repository.refresh(),
+ token -> {
+ refreshInFlight.set(false);
+ reconnectToken.setValue(token.getAccessToken());
+ },
+ error -> {
+ refreshInFlight.set(false);
+ authFailed.setValue("令牌刷新失败,请重新登录");
+ });
+ }
+
+ /** 拉取当前用户已绑定的被控端设备列表。 */
+ public void loadBindings() {
+ execute(
+ repository.bindings(),
+ response -> bindings.setValue(response.getBindings()),
+ error -> {
+ // 列表拉取失败不阻断主流程,保持静默
+ });
+ }
+
+ /** 退出登录:清空本地令牌。 */
+ public void logout() {
+ repository.logout();
+ }
}
diff --git a/WebRTCController/app/src/main/java/com/ttstd/controller/activity/splash/SplashActivity.java b/WebRTCController/app/src/main/java/com/ttstd/controller/activity/splash/SplashActivity.java
new file mode 100644
index 0000000..a791a67
--- /dev/null
+++ b/WebRTCController/app/src/main/java/com/ttstd/controller/activity/splash/SplashActivity.java
@@ -0,0 +1,46 @@
+package com.ttstd.controller.activity.splash;
+
+import android.content.Intent;
+
+import com.ttstd.controller.R;
+import com.ttstd.controller.activity.login.LoginActivity;
+import com.ttstd.controller.activity.main.MainActivity;
+import com.ttstd.controller.base.mvvm.BaseMvvmActivity;
+import com.ttstd.controller.databinding.ActivitySplashBinding;
+
+/**
+ * 启动页。
+ *
+ *
- *
- *
- *
+ *
+ */
+public final class PublicEndpoints {
+
+ private PublicEndpoints() {
+ }
+
+ /** 精确免鉴权路径(不区分 HTTP 方法)。 */
+ public static final Set