diff --git a/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java b/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java
new file mode 100644
index 0000000..898eaff
--- /dev/null
+++ b/admin/src/main/java/org/jiayunet/admin/constant/AdminRedisKeyName.java
@@ -0,0 +1,18 @@
+package org.jiayunet.admin.constant;
+
+/**
+ * B 端管理后台 Redis Key 常量
+ *
+ * @author zk
+ */
+public interface AdminRedisKeyName {
+
+ /** 管理员登录 token 前缀,完整 key: admin:login:token:{adminId} */
+ String LOGIN_TOKEN = "login:token:";
+
+ /** 登录失败次数计数,完整 key: admin:login:fail:{username} */
+ String LOGIN_FAIL_COUNT = "login:fail:";
+
+ /** 账号锁定标记,完整 key: admin:login:lock:{username} */
+ String LOGIN_LOCK = "login:lock:";
+}
diff --git a/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java b/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java
new file mode 100644
index 0000000..22ba64e
--- /dev/null
+++ b/admin/src/main/java/org/jiayunet/admin/controller/AdminLoginController.java
@@ -0,0 +1,29 @@
+package org.jiayunet.admin.controller;
+
+import lombok.AllArgsConstructor;
+import org.jiayunet.admin.pojo.param.login.AdminLoginParam;
+import org.jiayunet.admin.service.AdminLoginService;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 管理员登录控制类
+ *
+ * @author zk
+ */
+@RestController
+@RequestMapping("/public")
+@AllArgsConstructor
+@Validated
+public class AdminLoginController {
+
+ private AdminLoginService adminLoginService;
+
+ @PostMapping("/login")
+ public Long login(@Validated @RequestBody AdminLoginParam param, HttpServletRequest request, HttpServletResponse response) {
+ return adminLoginService.login(param.getUsername(), param.getPassword(), request, response);
+ }
+}
diff --git a/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java b/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java
new file mode 100644
index 0000000..d0dc47f
--- /dev/null
+++ b/admin/src/main/java/org/jiayunet/admin/pojo/param/login/AdminLoginParam.java
@@ -0,0 +1,23 @@
+package org.jiayunet.admin.pojo.param.login;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+
+/**
+ * 管理员登录入参
+ *
+ * @author zk
+ */
+@Data
+public class AdminLoginParam {
+
+ @NotBlank(message = "账号不能为空")
+ @Size(max = 32, message = "账号长度不能超过32位")
+ private String username;
+
+ @NotBlank(message = "密码不能为空")
+ @Size(min = 6, max = 32, message = "密码长度6-32位")
+ private String password;
+}
diff --git a/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java b/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java
new file mode 100644
index 0000000..f047c38
--- /dev/null
+++ b/admin/src/main/java/org/jiayunet/admin/service/AdminLoginService.java
@@ -0,0 +1,158 @@
+package org.jiayunet.admin.service;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.jiayunet.admin.constant.AdminRedisKeyName;
+import org.jiayunet.exception.BusinessException;
+import org.jiayunet.exception.BusinessExpCodeEnum;
+import org.jiayunet.mapper.AdminUserMapper;
+import org.jiayunet.pojo.login.RedisLoginTokenInfo;
+import org.jiayunet.pojo.po.AdminUser;
+import org.jiayunet.tool.HttpIpTool;
+import org.jiayunet.tool.server.RedisServerTool;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+/**
+ * 管理员登录服务
+ *
依赖:AdminUserMapper(管理员查询)、RedisServerTool(token管理与登录失败计数)
+ * 使用表:bg_admin_user(查询管理员账号)
+ * 使用Redis:login:token:{adminId}(登录令牌)、login:fail:{username}(失败计数)、login:lock:{username}(账号锁定)
+ *
+ * @author zk
+ */
+@Service
+@Slf4j
+public class AdminLoginService {
+
+ /** 密码错误允许最大次数 */
+ private static final int MAX_FAIL_COUNT = 3;
+ /** 账号锁定时长(小时) */
+ private static final int LOCK_HOURS = 24;
+
+ @Value("${app.secret.token:youweiqingnian123}")
+ private String secret;
+ @Value("${app.login.token.exceed_time:43200}")
+ private int tokenExceedTime;
+ @Value("${app.login.device_online_quantity:5}")
+ private int deviceOnlineQuantity;
+ @Autowired
+ private AdminUserMapper adminUserMapper;
+ @Autowired
+ private RedisServerTool redisServerTool;
+
+ /**
+ * 账号密码登录
+ * 1. 校验账号锁定状态 2. 查询管理员 3. 校验密码(失败累计,3次锁定24小时) 4. 生成JWT 5. 写入Redis管理多设备 6. 设置Cookie
+ */
+ public Long login(String username, String password, HttpServletRequest request, HttpServletResponse response) {
+ Assert.hasText(username, "账号不能为空");
+ Assert.hasText(password, "密码不能为空");
+
+ // 检查账号是否被锁定
+ Assert.isTrue(!redisServerTool.hasKey(AdminRedisKeyName.LOGIN_LOCK + username), "账号已被锁定,请24小时后再试");
+
+ // 查询管理员
+ AdminUser admin = adminUserMapper.selectOne(new LambdaQueryWrapper().eq(AdminUser::getUsername, username).eq(AdminUser::getStatus, 0));
+ Assert.notNull(admin, "账号或密码错误");
+
+ // 校验密码:MD5(明文 + salt)
+ if (!md5(password + secret).equals(admin.getPassword())) {
+ handleLoginFail(username);
+ throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "账号或密码错误");
+ }
+
+ // 登录成功,清除失败计数
+ clearLoginFail(username);
+
+ // 生成JWT
+ String uuId = UUID.randomUUID().toString().replace("-", "");
+ String token = JWT.create().withClaim("userId", admin.getId()).withClaim("uuId", uuId).sign(Algorithm.HMAC256(secret));
+
+ // 构建Redis登录信息
+ String redisKey = AdminRedisKeyName.LOGIN_TOKEN + admin.getId();
+ RedisLoginTokenInfo info = redisServerTool.get(redisKey, RedisLoginTokenInfo.class);
+ if (info == null) {
+ info = new RedisLoginTokenInfo();
+ info.setUserId(admin.getId());
+ info.setAuthority(new ArrayList<>());
+ info.setRole(new ArrayList<>());
+ }
+
+ // 过滤过期设备
+ List devices = info.getLoginDevices() == null ? new ArrayList<>() : info.getLoginDevices();
+ long expireMillis = System.currentTimeMillis() - tokenExceedTime * 1000L;
+ devices = devices.stream().filter(d -> d.getLastLoginTime().isAfter(Instant.ofEpochMilli(expireMillis))).collect(Collectors.toList());
+
+ // 超过设备上限,移除最早的
+ while (devices.size() >= deviceOnlineQuantity) {
+ devices.stream().min(Comparator.comparing(RedisLoginTokenInfo.LoginDevice::getLastLoginTime)).ifPresent(devices::remove);
+ }
+
+ // 添加当前设备
+ RedisLoginTokenInfo.LoginDevice device = new RedisLoginTokenInfo.LoginDevice();
+ device.setUuId(uuId);
+ device.setLastLoginTime(Instant.now());
+ device.setLoginIp(HttpIpTool.gteRealIP(request));
+ devices.add(device);
+
+ info.setLoginDevices(devices);
+ redisServerTool.set(redisKey, info, tokenExceedTime, TimeUnit.SECONDS);
+
+ // 设置Cookie
+ Cookie cookie = new Cookie("Token", token);
+ cookie.setHttpOnly(true);
+ cookie.setPath("/");
+ cookie.setMaxAge(tokenExceedTime);
+ response.addCookie(cookie);
+
+ return admin.getId();
+ }
+
+ /** 处理登录失败:累计失败次数,达到上限则锁定账号24小时 */
+ private void handleLoginFail(String username) {
+ String failKey = AdminRedisKeyName.LOGIN_FAIL_COUNT + username;
+ Integer failCount = redisServerTool.get(failKey, Integer.class);
+ int currentCount = (failCount == null ? 0 : failCount) + 1;
+ if (currentCount >= MAX_FAIL_COUNT) {
+ redisServerTool.set(AdminRedisKeyName.LOGIN_LOCK + username, true, LOCK_HOURS, TimeUnit.HOURS);
+ redisServerTool.delete(failKey);
+ log.warn("管理员账号 [{}] 密码错误{}次,已锁定{}小时", username, MAX_FAIL_COUNT, LOCK_HOURS);
+ } else {
+ redisServerTool.set(failKey, currentCount, LOCK_HOURS, TimeUnit.HOURS);
+ }
+ }
+
+ /** 登录成功后清除失败计数 */
+ private void clearLoginFail(String username) {
+ redisServerTool.delete(AdminRedisKeyName.LOGIN_FAIL_COUNT + username);
+ }
+
+ /** MD5 加密 */
+ private String md5(String input) {
+ try {
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+ byte[] digest = md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) { sb.append(String.format("%02x", b)); }
+ return sb.toString();
+ } catch (java.security.NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/admin/src/main/resources/static/img/logo.png b/admin/src/main/resources/static/img/logo.png
new file mode 100644
index 0000000..772ed7f
Binary files /dev/null and b/admin/src/main/resources/static/img/logo.png differ
diff --git a/admin/src/main/resources/static/js/api/auth.js b/admin/src/main/resources/static/js/api/auth.js
new file mode 100644
index 0000000..ebcbd86
--- /dev/null
+++ b/admin/src/main/resources/static/js/api/auth.js
@@ -0,0 +1,9 @@
+/**
+ * 认证相关 API
+ */
+const authApi = {
+ /** 登录 */
+ login(username, password) {
+ return request.post('/public/login', { username, password });
+ }
+};
diff --git a/admin/src/main/resources/static/js/request.js b/admin/src/main/resources/static/js/request.js
new file mode 100644
index 0000000..5be5d4c
--- /dev/null
+++ b/admin/src/main/resources/static/js/request.js
@@ -0,0 +1,54 @@
+/**
+ * 统一请求封装
+ */
+const request = (() => {
+ const BASE_URL = '/admin';
+
+ /** 从 cookie 中获取 Token */
+ function getToken() {
+ const match = document.cookie.match(/(?:^|;\s*)Token=([^;]*)/);
+ return match ? match[1] : '';
+ }
+
+ /** 统一请求方法 */
+ async function http(url, options = {}) {
+ const config = {
+ headers: { 'Content-Type': 'application/json', 'Token': getToken(), ...options.headers },
+ ...options
+ };
+ const response = await fetch(BASE_URL + url, config);
+
+ // 401 未登录,跳转登录页
+ if (response.status === 401) {
+ window.location.href = BASE_URL + '/page/login';
+ return Promise.reject('未登录');
+ }
+
+ const result = await response.json();
+
+ // 业务异常
+ if (result.status !== 200) {
+ const msg = result.message || '请求失败';
+ alert(msg);
+ return Promise.reject(msg);
+ }
+
+ return result.data;
+ }
+
+ return {
+ get(url, params) {
+ const query = params ? '?' + new URLSearchParams(params).toString() : '';
+ return http(url + query, { method: 'GET' });
+ },
+ post(url, data) {
+ return http(url, { method: 'POST', body: JSON.stringify(data) });
+ },
+ put(url, data) {
+ return http(url, { method: 'PUT', body: JSON.stringify(data) });
+ },
+ del(url, data) {
+ return http(url, { method: 'DELETE', body: data ? JSON.stringify(data) : undefined });
+ }
+ };
+})();
diff --git a/admin/src/main/resources/templates/login.html b/admin/src/main/resources/templates/login.html
index d58ffa5..7729ed5 100644
--- a/admin/src/main/resources/templates/login.html
+++ b/admin/src/main/resources/templates/login.html
@@ -3,10 +3,121 @@
- OfferPie 管理后台 - 登录
+ OfferPie 管理后台
+
+
- OfferPie 管理后台
- 登录页面占位 - 待实现
+
+
+
+

+
OfferPie
+
+
运营管理后台
+
+
+
+
+
欢迎回来
+
请登录管理员账号
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
+
+
+