feat: PSK 版本化管理 + DB 表 + 管理后台增删启禁
- V4 迁移: psks 表 (version, secret, description, is_active) - PskService: 启动导入 PAR_MEDIABOT_PSK → v1, verify 遍历所有活跃 PSK - HmacAuthInterceptor: PSK 签名优先验签,精简为 Bearer/PSK 双路径 - AdminController: GET/POST /psks + toggle 启禁 - AuthController: register/recover 改用 PskService 验证 - docker-compose.yml 新增 PAR_MEDIABOT_PSK 环境变量 mediabot 端只需: X-Signature = HMAC(PSK, 'email:ts')
This commit is contained in:
@@ -20,6 +20,7 @@ services:
|
|||||||
MYSQL_USER: "${MYSQL_USER:-root}"
|
MYSQL_USER: "${MYSQL_USER:-root}"
|
||||||
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-root}"
|
MYSQL_PASSWORD: "${MYSQL_PASSWORD:-root}"
|
||||||
HMAC_SECRET: "${HMAC_SECRET:-}"
|
HMAC_SECRET: "${HMAC_SECRET:-}"
|
||||||
|
PAR_MEDIABOT_PSK: "${PAR_MEDIABOT_PSK:-}"
|
||||||
PAR_STATIC_PATH: "/app/static"
|
PAR_STATIC_PATH: "/app/static"
|
||||||
PAR_ADMIN_PATH: "/app/static/admin"
|
PAR_ADMIN_PATH: "/app/static/admin"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import com.par.core.mapper.AccountMapper;
|
|||||||
import com.par.core.mapper.ConfigReviewMapper;
|
import com.par.core.mapper.ConfigReviewMapper;
|
||||||
import com.par.core.mapper.SiteConfigMapper;
|
import com.par.core.mapper.SiteConfigMapper;
|
||||||
import com.par.core.mapper.SiteMapper;
|
import com.par.core.mapper.SiteMapper;
|
||||||
|
import com.par.core.entity.Psk;
|
||||||
import com.par.core.service.AccountService;
|
import com.par.core.service.AccountService;
|
||||||
|
import com.par.core.service.PskService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +39,7 @@ public class AdminController {
|
|||||||
private final AccountMapper accountMapper;
|
private final AccountMapper accountMapper;
|
||||||
private final SiteMapper siteMapper;
|
private final SiteMapper siteMapper;
|
||||||
private final AccountService accountService;
|
private final AccountService accountService;
|
||||||
|
private final PskService pskService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有用户列表
|
* 获取所有用户列表
|
||||||
@@ -221,6 +225,34 @@ public class AdminController {
|
|||||||
return ApiResponse.success(accountService.listEmails(accountId));
|
return ApiResponse.success(accountService.listEmails(accountId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== PSK 管理 =====================
|
||||||
|
|
||||||
|
@GetMapping("/psks")
|
||||||
|
public ApiResponse<List<Psk>> listPsks(HttpServletRequest request) {
|
||||||
|
checkAdmin(request);
|
||||||
|
return ApiResponse.success(pskService.listAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/psks")
|
||||||
|
public ApiResponse<Psk> addPsk(@RequestBody Map<String, String> body, HttpServletRequest request) {
|
||||||
|
checkAdmin(request);
|
||||||
|
String version = body.get("version");
|
||||||
|
String secret = body.get("secret");
|
||||||
|
String description = body.get("description");
|
||||||
|
if (version == null || secret == null) {
|
||||||
|
return ApiResponse.error(400, "version and secret are required");
|
||||||
|
}
|
||||||
|
return ApiResponse.success(pskService.add(version, secret, description));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/psks/{id}/toggle")
|
||||||
|
public ApiResponse<Void> togglePsk(@PathVariable Long id, @RequestParam("active") boolean active,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
checkAdmin(request);
|
||||||
|
pskService.toggle(id, active);
|
||||||
|
return ApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
private void checkAdmin(HttpServletRequest request) {
|
private void checkAdmin(HttpServletRequest request) {
|
||||||
TrustLevel level = (TrustLevel) request.getAttribute("trustLevel");
|
TrustLevel level = (TrustLevel) request.getAttribute("trustLevel");
|
||||||
if (level == null || level != TrustLevel.ADMIN) {
|
if (level == null || level != TrustLevel.ADMIN) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.par.api.controller;
|
package com.par.api.controller;
|
||||||
|
|
||||||
import com.par.common.util.HmacUtil;
|
|
||||||
import com.par.core.dto.AccountDTO;
|
import com.par.core.dto.AccountDTO;
|
||||||
import com.par.core.dto.ApiResponse;
|
import com.par.core.dto.ApiResponse;
|
||||||
import com.par.core.dto.LoginRequest;
|
import com.par.core.dto.LoginRequest;
|
||||||
@@ -8,10 +7,10 @@ import com.par.core.dto.RegisterRequest;
|
|||||||
import com.par.core.entity.Account;
|
import com.par.core.entity.Account;
|
||||||
import com.par.core.interceptor.HmacAuthInterceptor;
|
import com.par.core.interceptor.HmacAuthInterceptor;
|
||||||
import com.par.core.service.AccountService;
|
import com.par.core.service.AccountService;
|
||||||
|
import com.par.core.service.PskService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -27,13 +26,13 @@ public class AuthController {
|
|||||||
|
|
||||||
private final AccountService accountService;
|
private final AccountService accountService;
|
||||||
private final HmacAuthInterceptor hmacAuthInterceptor;
|
private final HmacAuthInterceptor hmacAuthInterceptor;
|
||||||
|
private final PskService pskService;
|
||||||
|
|
||||||
@Value("${par.mediabot.psk:}")
|
public AuthController(AccountService accountService, HmacAuthInterceptor hmacAuthInterceptor,
|
||||||
private String mediabotPsk;
|
PskService pskService) {
|
||||||
|
|
||||||
public AuthController(AccountService accountService, HmacAuthInterceptor hmacAuthInterceptor) {
|
|
||||||
this.accountService = accountService;
|
this.accountService = accountService;
|
||||||
this.hmacAuthInterceptor = hmacAuthInterceptor;
|
this.hmacAuthInterceptor = hmacAuthInterceptor;
|
||||||
|
this.pskService = pskService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,15 +115,14 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 X-Medibot-Timestamp + X-Medibot-Signature
|
* 验证 X-Medibot-Timestamp + X-Medibot-Signature(通过 PskService)
|
||||||
*/
|
*/
|
||||||
private boolean verifyMediabotPsk(HttpServletRequest request, String email) {
|
private boolean verifyMediabotPsk(HttpServletRequest request, String email) {
|
||||||
String ts = request.getHeader("X-Medibot-Timestamp");
|
String ts = request.getHeader("X-Medibot-Timestamp");
|
||||||
String sig = request.getHeader("X-Medibot-Signature");
|
String sig = request.getHeader("X-Medibot-Signature");
|
||||||
if (ts == null || sig == null) return false;
|
if (ts == null || sig == null) return false;
|
||||||
try {
|
try {
|
||||||
long timestamp = Long.parseLong(ts);
|
return pskService.verify(email, Long.parseLong(ts), sig) != null;
|
||||||
return HmacUtil.verifyMediabotPsk(email, timestamp, sig, mediabotPsk);
|
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
32
par-core/src/main/java/com/par/core/entity/Psk.java
Normal file
32
par-core/src/main/java/com/par/core/entity/Psk.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.par.core.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PSK 密钥实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("psks")
|
||||||
|
public class Psk {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@TableField("version")
|
||||||
|
private String version;
|
||||||
|
|
||||||
|
@TableField("secret")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@TableField("description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@TableField("is_active")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
@TableField(value = "created_at", fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.par.core.interceptor;
|
|||||||
import com.par.common.util.HmacUtil;
|
import com.par.common.util.HmacUtil;
|
||||||
import com.par.core.entity.Account;
|
import com.par.core.entity.Account;
|
||||||
import com.par.core.service.AccountService;
|
import com.par.core.service.AccountService;
|
||||||
|
import com.par.core.service.PskService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -14,7 +15,6 @@ import org.springframework.web.servlet.HandlerInterceptor;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 鉴权拦截器
|
* API 鉴权拦截器
|
||||||
@@ -29,6 +29,7 @@ import java.util.stream.Collectors;
|
|||||||
public class HmacAuthInterceptor implements HandlerInterceptor {
|
public class HmacAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private final AccountService accountService;
|
private final AccountService accountService;
|
||||||
|
private final PskService pskService;
|
||||||
|
|
||||||
@Value("${hmac.secret}")
|
@Value("${hmac.secret}")
|
||||||
private String hmacSecret;
|
private String hmacSecret;
|
||||||
@@ -62,73 +63,29 @@ public class HmacAuthInterceptor implements HandlerInterceptor {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======== 方式 2:HMAC 签名认证(外部 API 客户端) ========
|
// ======== 方式 2:PSK 签名认证(mediabot 等集成客户端) ========
|
||||||
String signature = request.getHeader(HEADER_SIGNATURE);
|
String pskSig = request.getHeader(HEADER_SIGNATURE);
|
||||||
String timestampStr = request.getHeader(HEADER_TIMESTAMP);
|
String ts = request.getHeader(HEADER_TIMESTAMP);
|
||||||
String email = request.getHeader(HEADER_EMAIL);
|
String email = request.getHeader(HEADER_EMAIL);
|
||||||
|
if (pskSig != null && ts != null && email != null) {
|
||||||
if (signature == null || timestampStr == null || email == null) {
|
|
||||||
writeError(response, 401, "Missing authentication headers");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验时间戳(防重放)
|
|
||||||
long timestamp;
|
|
||||||
try {
|
try {
|
||||||
timestamp = Long.parseLong(timestampStr);
|
long timestamp = Long.parseLong(ts);
|
||||||
} catch (NumberFormatException e) {
|
String pskVersion = pskService.verify(email, timestamp, pskSig);
|
||||||
writeError(response, 401, "Invalid timestamp");
|
if (pskVersion != null) {
|
||||||
return false;
|
request.setAttribute("accountId", 0L);
|
||||||
}
|
request.setAttribute("trustLevel", com.par.core.enums.TrustLevel.TRUSTED);
|
||||||
|
request.setAttribute("pskVersion", pskVersion);
|
||||||
long now = System.currentTimeMillis() / 1000;
|
|
||||||
if (Math.abs(now - timestamp) > HMAC_WINDOW_SECONDS) {
|
|
||||||
writeError(response, 401, "Request expired or clock skew too large");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找账户(支持主邮箱和关联邮箱)
|
|
||||||
Account account = accountService.findByAnyEmail(email);
|
|
||||||
|
|
||||||
// 计算 body hash
|
|
||||||
String body = readBody(request);
|
|
||||||
String bodyHash = HmacUtil.sha256(body);
|
|
||||||
|
|
||||||
// 构建签名内容
|
|
||||||
String signContent = HmacUtil.buildSignContent(
|
|
||||||
request.getMethod(),
|
|
||||||
request.getRequestURI(),
|
|
||||||
timestamp,
|
|
||||||
bodyHash
|
|
||||||
);
|
|
||||||
|
|
||||||
boolean valid;
|
|
||||||
if (account != null && Boolean.TRUE.equals(account.getIsActive())) {
|
|
||||||
// 已有账户:用 api_secret 验签
|
|
||||||
if (account.getApiSecret() == null || account.getApiSecret().isBlank()) {
|
|
||||||
writeError(response, 401, "API credentials not configured");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
valid = HmacUtil.verify(account.getApiSecret(), signContent, signature);
|
|
||||||
} else {
|
|
||||||
// 未知邮箱:尝试用确定性推导的 secret 验签,匹配则自动创建账户
|
|
||||||
String derivedSecret = HmacUtil.deriveApiSecret(email, hmacSecret);
|
|
||||||
valid = HmacUtil.verify(derivedSecret, signContent, signature);
|
|
||||||
if (valid) {
|
|
||||||
account = autoCreateAccount(email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!valid) {
|
|
||||||
writeError(response, 401, "Invalid signature");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将账户ID写入请求属性,供后续使用
|
|
||||||
request.setAttribute("accountId", account.getId());
|
|
||||||
request.setAttribute("trustLevel", account.getTrustLevel());
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
writeError(response, 401, "Invalid PSK signature");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 方式 3:无有效认证头 → 拒绝 ========
|
||||||
|
writeError(response, 401, "Missing or invalid authentication");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private String readBody(HttpServletRequest request) throws IOException {
|
private String readBody(HttpServletRequest request) throws IOException {
|
||||||
if (request.getContentLength() <= 0) {
|
if (request.getContentLength() <= 0) {
|
||||||
|
|||||||
18
par-core/src/main/java/com/par/core/mapper/PskMapper.java
Normal file
18
par-core/src/main/java/com/par/core/mapper/PskMapper.java
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package com.par.core.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.par.core.entity.Psk;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PSK Mapper
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PskMapper extends BaseMapper<Psk> {
|
||||||
|
|
||||||
|
@Select("SELECT * FROM psks WHERE is_active = 1")
|
||||||
|
List<Psk> selectActive();
|
||||||
|
}
|
||||||
103
par-core/src/main/java/com/par/core/service/PskService.java
Normal file
103
par-core/src/main/java/com/par/core/service/PskService.java
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package com.par.core.service;
|
||||||
|
|
||||||
|
import com.par.common.util.HmacUtil;
|
||||||
|
import com.par.core.entity.Psk;
|
||||||
|
import com.par.core.mapper.PskMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PSK 管理服务
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PskService {
|
||||||
|
|
||||||
|
private final PskMapper pskMapper;
|
||||||
|
|
||||||
|
@Value("${par.mediabot.psk:}")
|
||||||
|
private String envPsk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时:如果配置了环境变量 PSK 且 DB 中不存在,自动导入为 v1
|
||||||
|
*/
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void initDefaultPsk() {
|
||||||
|
if (envPsk == null || envPsk.isBlank()) return;
|
||||||
|
List<Psk> all = pskMapper.selectList(null);
|
||||||
|
// 检查是否有相同 secret 的 PSK 已存在
|
||||||
|
boolean exists = all.stream().anyMatch(p -> envPsk.equals(p.getSecret()));
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
Psk psk = new Psk();
|
||||||
|
psk.setVersion(all.isEmpty() ? "v1" : "v" + (all.size() + 1));
|
||||||
|
psk.setSecret(envPsk);
|
||||||
|
psk.setDescription("Imported from PAR_MEDIABOT_PSK");
|
||||||
|
psk.setIsActive(true);
|
||||||
|
pskMapper.insert(psk);
|
||||||
|
log.info("Imported PSK {} from environment", psk.getVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有活跃 PSK
|
||||||
|
*/
|
||||||
|
public List<Psk> listActive() {
|
||||||
|
return pskMapper.selectActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 PSK(含禁用的)
|
||||||
|
*/
|
||||||
|
public List<Psk> listAll() {
|
||||||
|
return pskMapper.selectList(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加 PSK
|
||||||
|
*/
|
||||||
|
public Psk add(String version, String secret, String description) {
|
||||||
|
Psk psk = new Psk();
|
||||||
|
psk.setVersion(version);
|
||||||
|
psk.setSecret(secret);
|
||||||
|
psk.setDescription(description);
|
||||||
|
psk.setIsActive(true);
|
||||||
|
pskMapper.insert(psk);
|
||||||
|
return psk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用
|
||||||
|
*/
|
||||||
|
public void toggle(Long id, boolean active) {
|
||||||
|
Psk psk = pskMapper.selectById(id);
|
||||||
|
if (psk == null) throw new IllegalArgumentException("PSK not found");
|
||||||
|
psk.setIsActive(active);
|
||||||
|
pskMapper.updateById(psk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用所有活跃 PSK 逐一验证签名
|
||||||
|
* @return 匹配的 PSK 版本号,null 表示验证失败
|
||||||
|
*/
|
||||||
|
public String verify(String email, long timestamp, String signature) {
|
||||||
|
for (Psk psk : pskMapper.selectActive()) {
|
||||||
|
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, psk.getSecret())) {
|
||||||
|
return psk.getVersion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fallback: 也试 env PSK(DB 丢失时仍可用)
|
||||||
|
if (envPsk != null && !envPsk.isBlank()) {
|
||||||
|
if (HmacUtil.verifyMediabotPsk(email, timestamp, signature, envPsk)) {
|
||||||
|
return "env";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
par-core/src/main/resources/db/migration/V4__psk_table.sql
Normal file
11
par-core/src/main/resources/db/migration/V4__psk_table.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- PAR Flyway V4: PSK 版本管理
|
||||||
|
CREATE TABLE psks (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
version VARCHAR(20) NOT NULL COMMENT 'PSK 版本号(如 v1, v2)',
|
||||||
|
secret VARCHAR(255) NOT NULL COMMENT 'PSK 密钥值',
|
||||||
|
description VARCHAR(200) COMMENT '描述(哪个客户端在用)',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_version (version)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PSK 版本管理表';
|
||||||
Reference in New Issue
Block a user