feat: add secure store onboarding and staff invites
This commit is contained in:
parent
1887864d38
commit
207115e18f
15
README.md
15
README.md
@ -107,6 +107,12 @@ curl http://localhost:8080/api/store/list
|
|||||||
```
|
```
|
||||||
脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。
|
脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。
|
||||||
|
|
||||||
|
9. **门店开通、员工邀请与操作审计**:报告发送状态验证完成后执行:
|
||||||
|
```bash
|
||||||
|
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_store_onboarding.sql
|
||||||
|
```
|
||||||
|
脚本把历史门店开通状态标为不可推断的 `unknown`,建立只保存 token SHA-256 的一次性员工邀请和不可变低敏审计表。末尾六项状态/回执一致性验证必须全部为 0;不得恢复 legacy 永久邀请码注册。
|
||||||
|
|
||||||
### production profile
|
### production profile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -122,14 +128,14 @@ production profile 下:
|
|||||||
|
|
||||||
### 生产只读预检
|
### 生产只读预检
|
||||||
|
|
||||||
完成备份和五个迁移后,以最终生产环境变量运行:
|
完成备份和六个迁移后,以最终生产环境变量运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
||||||
BUILD_FIRST=1 deploy/release-preflight.sh
|
BUILD_FIRST=1 deploy/release-preflight.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 18 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 24 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
@ -186,6 +192,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
|||||||
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
||||||
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
||||||
- [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0
|
- [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0
|
||||||
|
- [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0
|
||||||
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
||||||
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
||||||
- [ ] readiness 与上线后只读冒烟全部通过
|
- [ ] readiness 与上线后只读冒烟全部通过
|
||||||
@ -193,12 +200,14 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
|||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|
||||||
- **最小鉴权上下文**:HMAC session token(`SessionTokenService`)+ `AuthInterceptor`,不引入完整 Spring Security。
|
- **最小鉴权上下文**:HMAC session token(`SessionTokenService`)+ `AuthInterceptor`,并在每次受保护请求核验活跃账号的 role/storeId 未变化;删除员工会立即使旧会话失效。
|
||||||
- **公开接口白名单**:见 `AuthInterceptor.PUBLIC_PATTERNS`;其余 `/api/**` 要求 `Authorization: Bearer <token>`。
|
- **公开接口白名单**:见 `AuthInterceptor.PUBLIC_PATTERNS`;其余 `/api/**` 要求 `Authorization: Bearer <token>`。
|
||||||
- **跨店/跨用户边界**:所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`,不信任请求体里的身份字段。
|
- **跨店/跨用户边界**:所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`,不信任请求体里的身份字段。
|
||||||
- **公开报告页**:`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`;日志只记 token SHA-256 前 8 位 hash。
|
- **公开报告页**:`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`;日志只记 token SHA-256 前 8 位 hash。
|
||||||
- **回访池脱敏**:`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`,只返回 `wechatBound` + 脱敏 id。
|
- **回访池脱敏**:`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`,只返回 `wechatBound` + 脱敏 id。
|
||||||
- **上传路径保护**:`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。
|
- **上传路径保护**:`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。
|
||||||
|
- **员工邀请**:原始 256-bit token 只在创建响应返回一次,数据库只存 SHA-256;邀请绑定微信核验手机号、限时、一次使用、可撤销。
|
||||||
|
- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLog,metadata 禁止 PII、凭证和原始请求体。
|
||||||
|
|
||||||
## 参与贡献
|
## 参与贡献
|
||||||
|
|
||||||
|
|||||||
89
db/migrations/20260802_create_store_onboarding.sql
Normal file
89
db/migrations/20260802_create_store_onboarding.sql
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
-- Petstore Phase 0:门店开通状态、一次性员工邀请与不可变操作审计
|
||||||
|
-- 前置:20260801_create_report_send_status.sql 已执行。
|
||||||
|
-- 语义:历史门店开通状态不可推断,统一为 unknown;新门店由应用写 in_progress。
|
||||||
|
|
||||||
|
ALTER TABLE t_store
|
||||||
|
ADD COLUMN onboarding_status VARCHAR(16) NULL COMMENT 'unknown | in_progress | completed';
|
||||||
|
|
||||||
|
ALTER TABLE t_store
|
||||||
|
ADD COLUMN onboarding_completed_at DATETIME NULL COMMENT '老板显式完成开通时间';
|
||||||
|
|
||||||
|
ALTER TABLE t_store
|
||||||
|
ADD COLUMN onboarding_completed_by_user_id BIGINT NULL COMMENT '显式完成开通的老板用户';
|
||||||
|
|
||||||
|
UPDATE t_store
|
||||||
|
SET onboarding_status = 'unknown';
|
||||||
|
|
||||||
|
ALTER TABLE t_store
|
||||||
|
MODIFY COLUMN onboarding_status VARCHAR(16) NOT NULL DEFAULT 'in_progress'
|
||||||
|
COMMENT 'unknown | in_progress | completed';
|
||||||
|
|
||||||
|
CREATE TABLE t_staff_invitation (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
invitation_id VARCHAR(36) NOT NULL,
|
||||||
|
token_hash VARCHAR(64) NOT NULL COMMENT '原始 token 的 SHA-256;原始 token 不落库',
|
||||||
|
store_id BIGINT NOT NULL,
|
||||||
|
invited_name VARCHAR(64) NOT NULL,
|
||||||
|
invited_phone VARCHAR(20) NOT NULL COMMENT '仅服务端用于微信核验匹配;客户端只返回脱敏值',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending | accepted | revoked | expired',
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_by_user_id BIGINT NOT NULL,
|
||||||
|
accepted_by_user_id BIGINT NULL,
|
||||||
|
accepted_at DATETIME NULL,
|
||||||
|
revoked_by_user_id BIGINT NULL,
|
||||||
|
revoked_at DATETIME NULL,
|
||||||
|
create_time DATETIME NOT NULL,
|
||||||
|
update_time DATETIME NOT NULL,
|
||||||
|
CONSTRAINT uk_staff_invitation_public_id UNIQUE (invitation_id),
|
||||||
|
CONSTRAINT uk_staff_invitation_token_hash UNIQUE (token_hash),
|
||||||
|
INDEX idx_staff_invite_store_status (store_id, status, expires_at),
|
||||||
|
INDEX idx_staff_invite_phone (store_id, invited_phone, status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时、一次性员工邀请';
|
||||||
|
|
||||||
|
CREATE TABLE t_audit_log (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
audit_id VARCHAR(36) NOT NULL,
|
||||||
|
store_id BIGINT NOT NULL,
|
||||||
|
actor_user_id BIGINT NULL,
|
||||||
|
actor_role VARCHAR(16) NULL,
|
||||||
|
action VARCHAR(64) NOT NULL,
|
||||||
|
target_type VARCHAR(32) NOT NULL,
|
||||||
|
target_id VARCHAR(64) NULL,
|
||||||
|
outcome VARCHAR(16) NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL COMMENT '服务端白名单低敏标量 JSON',
|
||||||
|
occurred_at DATETIME NOT NULL,
|
||||||
|
create_time DATETIME NOT NULL,
|
||||||
|
CONSTRAINT uk_audit_log_audit_id UNIQUE (audit_id),
|
||||||
|
INDEX idx_audit_store_time (store_id, occurred_at),
|
||||||
|
INDEX idx_audit_action_time (action, occurred_at),
|
||||||
|
INDEX idx_audit_target (target_type, target_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变操作审计';
|
||||||
|
|
||||||
|
-- 验证:以下六项必须全部为 0。
|
||||||
|
SELECT COUNT(*) AS invalid_store_onboarding_status_count
|
||||||
|
FROM t_store
|
||||||
|
WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed');
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS completed_onboarding_without_receipt_count
|
||||||
|
FROM t_store
|
||||||
|
WHERE onboarding_status = 'completed'
|
||||||
|
AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL);
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS incomplete_onboarding_with_receipt_count
|
||||||
|
FROM t_store
|
||||||
|
WHERE onboarding_status IN ('unknown', 'in_progress')
|
||||||
|
AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL);
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS invalid_staff_invitation_status_count
|
||||||
|
FROM t_staff_invitation
|
||||||
|
WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired');
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS inconsistent_staff_invitation_acceptance_count
|
||||||
|
FROM t_staff_invitation
|
||||||
|
WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL))
|
||||||
|
OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL));
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS inconsistent_staff_invitation_revocation_count
|
||||||
|
FROM t_staff_invitation
|
||||||
|
WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL))
|
||||||
|
OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL));
|
||||||
@ -18,4 +18,6 @@
|
|||||||
|
|
||||||
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
|
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
|
||||||
|
|
||||||
|
`20260802_create_store_onboarding.sql` 必须在报告发送状态迁移之后执行:为历史门店写入不可推断的 `unknown` 开通状态,建立只存 token 摘要的限时员工邀请与低敏不可变操作审计。脚本末尾六个验证计数必须为 0;不得用 legacy `invite_code` 恢复公开员工注册。
|
||||||
|
|
||||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
# Dedicated Petstore server blocks. Replace domains/certificate paths before use.
|
# Dedicated Petstore server blocks. Replace domains/certificate paths before use.
|
||||||
# Keep these blocks separate from Gitea/GitLab configurations on a shared host.
|
# Keep these blocks separate from Gitea/GitLab configurations on a shared host.
|
||||||
|
|
||||||
|
# Deliberately log $uri instead of $request_uri/$request so report and invitation
|
||||||
|
# tokens carried in query strings never enter Nginx access logs.
|
||||||
|
log_format petstore_safe '$remote_addr - $remote_user [$time_local] '
|
||||||
|
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||||
|
'"$http_referer" "$http_user_agent"';
|
||||||
|
|
||||||
upstream petstore_backend_8080 {
|
upstream petstore_backend_8080 {
|
||||||
server 127.0.0.1:8080;
|
server 127.0.0.1:8080;
|
||||||
keepalive 16;
|
keepalive 16;
|
||||||
@ -13,6 +19,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/api.petstore.invalid/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/api.petstore.invalid/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 200m;
|
client_max_body_size 200m;
|
||||||
|
access_log /var/log/nginx/petstore-api.access.log petstore_safe;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://petstore_backend_8080;
|
proxy_pass http://petstore_backend_8080;
|
||||||
@ -33,6 +40,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
ssl_certificate /etc/letsencrypt/live/admin.petstore.invalid/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/admin.petstore.invalid/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/admin.petstore.invalid/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/admin.petstore.invalid/privkey.pem;
|
||||||
|
access_log /var/log/nginx/petstore-admin.access.log petstore_safe;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://petstore_backend_8080;
|
proxy_pass http://petstore_backend_8080;
|
||||||
@ -55,6 +63,7 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
ssl_certificate /etc/letsencrypt/live/report.petstore.invalid/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/report.petstore.invalid/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/report.petstore.invalid/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/report.petstore.invalid/privkey.pem;
|
||||||
|
access_log /var/log/nginx/petstore-report.access.log petstore_safe;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
package com.petstore.auth;
|
package com.petstore.auth;
|
||||||
|
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
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;
|
||||||
@ -28,6 +31,8 @@ import java.util.Optional;
|
|||||||
public class AuthInterceptor implements HandlerInterceptor {
|
public class AuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private final SessionTokenService sessionTokenService;
|
private final SessionTokenService sessionTokenService;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final StoreMapper storeMapper;
|
||||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,6 +47,9 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
"POST /api/user/wx-phone-login",
|
"POST /api/user/wx-phone-login",
|
||||||
"POST /api/user/register-boss",
|
"POST /api/user/register-boss",
|
||||||
"POST /api/user/register-staff",
|
"POST /api/user/register-staff",
|
||||||
|
"POST /api/onboarding/register-boss",
|
||||||
|
"GET /api/staff-invitations/preview",
|
||||||
|
"POST /api/staff-invitations/accept",
|
||||||
"POST /api/sms/send",
|
"POST /api/sms/send",
|
||||||
"GET /api/store/list",
|
"GET /api/store/list",
|
||||||
"GET /api/store/get",
|
"GET /api/store/get",
|
||||||
@ -78,7 +86,18 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
if (opt.isEmpty()) {
|
if (opt.isEmpty()) {
|
||||||
return reject(response, "登录已失效,请重新登录");
|
return reject(response, "登录已失效,请重新登录");
|
||||||
}
|
}
|
||||||
CurrentUserContext.set(opt.get());
|
CurrentUser current = opt.get();
|
||||||
|
User activeUser = userMapper.findByIdAndDeletedFalse(current.userId()).orElse(null);
|
||||||
|
if (activeUser == null
|
||||||
|
|| !java.util.Objects.equals(activeUser.getStoreId(), current.storeId())
|
||||||
|
|| !java.util.Objects.equals(activeUser.getRole(), current.role())) {
|
||||||
|
return reject(response, "账号权限已变更,请重新登录");
|
||||||
|
}
|
||||||
|
if (current.isStoreUser()
|
||||||
|
&& (current.storeId() == null || storeMapper.findByIdAndDeletedFalse(current.storeId()).isEmpty())) {
|
||||||
|
return reject(response, "门店已停用,请联系管理员");
|
||||||
|
}
|
||||||
|
CurrentUserContext.set(current);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,34 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner {
|
|||||||
SELECT COUNT(*) FROM t_report
|
SELECT COUNT(*) FROM t_report
|
||||||
WHERE send_status IN ('unknown', 'unsent')
|
WHERE send_status IN ('unknown', 'unsent')
|
||||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_store_onboarding_status", """
|
||||||
|
SELECT COUNT(*) FROM t_store
|
||||||
|
WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed')
|
||||||
|
"""),
|
||||||
|
new Invariant("completed_onboarding_without_receipt", """
|
||||||
|
SELECT COUNT(*) FROM t_store
|
||||||
|
WHERE onboarding_status = 'completed'
|
||||||
|
AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL)
|
||||||
|
"""),
|
||||||
|
new Invariant("incomplete_onboarding_with_receipt", """
|
||||||
|
SELECT COUNT(*) FROM t_store
|
||||||
|
WHERE onboarding_status IN ('unknown', 'in_progress')
|
||||||
|
AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL)
|
||||||
|
"""),
|
||||||
|
new Invariant("invalid_staff_invitation_status", """
|
||||||
|
SELECT COUNT(*) FROM t_staff_invitation
|
||||||
|
WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired')
|
||||||
|
"""),
|
||||||
|
new Invariant("inconsistent_staff_invitation_acceptance", """
|
||||||
|
SELECT COUNT(*) FROM t_staff_invitation
|
||||||
|
WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL))
|
||||||
|
OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL))
|
||||||
|
"""),
|
||||||
|
new Invariant("inconsistent_staff_invitation_revocation", """
|
||||||
|
SELECT COUNT(*) FROM t_staff_invitation
|
||||||
|
WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL))
|
||||||
|
OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL))
|
||||||
""")
|
""")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.petstore.controller;
|
||||||
|
|
||||||
|
import com.petstore.auth.CurrentUser;
|
||||||
|
import com.petstore.auth.CurrentUserContext;
|
||||||
|
import com.petstore.service.FlowException;
|
||||||
|
import com.petstore.service.StoreOnboardingService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 门店开通清单:门店成员可查看,只有老板可显式完成。 */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/onboarding")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin
|
||||||
|
public class AdminOnboardingController {
|
||||||
|
|
||||||
|
private final StoreOnboardingService onboardingService;
|
||||||
|
|
||||||
|
@GetMapping("")
|
||||||
|
public Map<String, Object> status() {
|
||||||
|
CurrentUser user = CurrentUserContext.require();
|
||||||
|
if (!user.isStoreUser() || user.storeId() == null) {
|
||||||
|
return Map.of("code", 403, "message", "仅门店成员可查看开通清单", "bizCode", "FORBIDDEN");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Map.of("code", 200, "data", onboardingService.getStatus(user.storeId()));
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/complete")
|
||||||
|
public Map<String, Object> complete() {
|
||||||
|
CurrentUser user = CurrentUserContext.require();
|
||||||
|
if (!user.isBoss() || user.storeId() == null) {
|
||||||
|
return Map.of("code", 403, "message", "仅老板可完成门店开通", "bizCode", "FORBIDDEN");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Map.of(
|
||||||
|
"code", 200,
|
||||||
|
"message", "门店已完成开通,可进入真实试点",
|
||||||
|
"data", onboardingService.complete(user.storeId(), user.userId(), user.role())
|
||||||
|
);
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门店 Web 后台:本店资料(含 inviteCode 等内部字段)。
|
* 门店 Web 后台:本店资料。legacy inviteCode 不再返回客户端。
|
||||||
* 规则:{@code rule:BR-ADMIN-001}、{@code rule:BR-ADMIN-002}、{@code rule:BR-ADMIN-003}
|
* 规则:{@code rule:BR-ADMIN-001}、{@code rule:BR-ADMIN-002}、{@code rule:BR-ADMIN-003}
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@ -44,7 +44,8 @@ public class AdminStoreController {
|
|||||||
data.put("longitude", store.getLongitude());
|
data.put("longitude", store.getLongitude());
|
||||||
data.put("logo", store.getLogo());
|
data.put("logo", store.getLogo());
|
||||||
data.put("intro", store.getIntro());
|
data.put("intro", store.getIntro());
|
||||||
data.put("inviteCode", store.getInviteCode());
|
data.put("onboardingStatus", store.getOnboardingStatus());
|
||||||
|
data.put("onboardingCompletedAt", store.getOnboardingCompletedAt());
|
||||||
data.put("bookingDayStart", store.getBookingDayStart() == null ? null : store.getBookingDayStart().toString());
|
data.put("bookingDayStart", store.getBookingDayStart() == null ? null : store.getBookingDayStart().toString());
|
||||||
data.put("bookingLastSlotStart",
|
data.put("bookingLastSlotStart",
|
||||||
store.getBookingLastSlotStart() == null ? null : store.getBookingLastSlotStart().toString());
|
store.getBookingLastSlotStart() == null ? null : store.getBookingLastSlotStart().toString());
|
||||||
|
|||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.petstore.controller;
|
||||||
|
|
||||||
|
import com.petstore.service.FlowException;
|
||||||
|
import com.petstore.service.MerchantOnboardingService;
|
||||||
|
import com.petstore.service.WechatMiniProgramService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 新门店通过微信核验手机号后自助开通。 */
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/onboarding")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin
|
||||||
|
public class MerchantOnboardingController {
|
||||||
|
|
||||||
|
private final MerchantOnboardingService onboardingService;
|
||||||
|
private final WechatMiniProgramService wechatMiniProgramService;
|
||||||
|
|
||||||
|
@PostMapping("/register-boss")
|
||||||
|
public Map<String, Object> registerBoss(@RequestBody(required = false) Map<String, String> params) {
|
||||||
|
if (params == null) {
|
||||||
|
return error(400, "INVALID_ONBOARDING_INPUT", "请求体不能为空");
|
||||||
|
}
|
||||||
|
String phoneCode = params.get("phoneCode");
|
||||||
|
if (phoneCode == null || phoneCode.isBlank()) {
|
||||||
|
return error(400, "WECHAT_PHONE_REQUIRED", "请先授权微信手机号");
|
||||||
|
}
|
||||||
|
var phoneResult = wechatMiniProgramService.exchangePhoneCode(phoneCode);
|
||||||
|
if (!phoneResult.isOk()) {
|
||||||
|
return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
String openid = null;
|
||||||
|
String unionid = null;
|
||||||
|
String loginCode = params.get("loginCode");
|
||||||
|
if (loginCode != null && !loginCode.isBlank()) {
|
||||||
|
var identity = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||||
|
if (identity.isOk()) {
|
||||||
|
openid = identity.openid();
|
||||||
|
unionid = identity.unionid();
|
||||||
|
} else {
|
||||||
|
log.debug("老板注册时 openid 沉淀跳过: {}", identity.errorMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> data = onboardingService.registerVerifiedBoss(
|
||||||
|
params.get("storeName"), params.get("bossName"), phoneResult.phone(), openid, unionid
|
||||||
|
);
|
||||||
|
return Map.of("code", 200, "message", "门店创建成功,请继续完成开通清单", "data", data);
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> error(int code, String bizCode, String message) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("code", code);
|
||||||
|
result.put("bizCode", bizCode);
|
||||||
|
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,138 @@
|
|||||||
|
package com.petstore.controller;
|
||||||
|
|
||||||
|
import com.petstore.auth.CurrentUser;
|
||||||
|
import com.petstore.auth.CurrentUserContext;
|
||||||
|
import com.petstore.service.FlowException;
|
||||||
|
import com.petstore.service.StaffInvitationService;
|
||||||
|
import com.petstore.service.WechatMiniProgramService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 老板签发/撤销邀请;员工在小程序用微信核验手机号后接受。 */
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin
|
||||||
|
public class StaffInvitationController {
|
||||||
|
|
||||||
|
private final StaffInvitationService invitationService;
|
||||||
|
private final WechatMiniProgramService wechatMiniProgramService;
|
||||||
|
|
||||||
|
@PostMapping("/admin/staff-invitations")
|
||||||
|
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
|
||||||
|
CurrentUser user = CurrentUserContext.require();
|
||||||
|
if (!user.isBoss() || user.storeId() == null) {
|
||||||
|
return error(403, "FORBIDDEN", "仅老板可邀请员工");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Integer validDays = integerOrNull(params.get("validDays"));
|
||||||
|
Map<String, Object> data = invitationService.create(
|
||||||
|
user.storeId(), user.userId(), user.role(), text(params.get("name")),
|
||||||
|
text(params.get("phone")), validDays
|
||||||
|
);
|
||||||
|
return Map.of("code", 200, "message", "邀请已创建,请立即复制邀请链接", "data", data);
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return error(400, "INVALID_EXPIRY", "邀请有效期格式不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/staff-invitations")
|
||||||
|
public Map<String, Object> list() {
|
||||||
|
CurrentUser user = CurrentUserContext.require();
|
||||||
|
if (!user.isBoss() || user.storeId() == null) {
|
||||||
|
return error(403, "FORBIDDEN", "仅老板可查看员工邀请");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Map.of("code", 200, "data", invitationService.list(user.storeId()));
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/admin/staff-invitations")
|
||||||
|
public Map<String, Object> revoke(@RequestParam String invitationId) {
|
||||||
|
CurrentUser user = CurrentUserContext.require();
|
||||||
|
if (!user.isBoss() || user.storeId() == null) {
|
||||||
|
return error(403, "FORBIDDEN", "仅老板可撤销员工邀请");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Map.of(
|
||||||
|
"code", 200,
|
||||||
|
"message", "邀请已撤销",
|
||||||
|
"data", invitationService.revoke(user.storeId(), user.userId(), user.role(), invitationId)
|
||||||
|
);
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/staff-invitations/preview")
|
||||||
|
public Map<String, Object> preview(@RequestParam String token) {
|
||||||
|
try {
|
||||||
|
return Map.of("code", 200, "data", invitationService.preview(token));
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/staff-invitations/accept")
|
||||||
|
public Map<String, Object> accept(@RequestBody(required = false) Map<String, String> params) {
|
||||||
|
if (params == null || params.get("token") == null || params.get("phoneCode") == null) {
|
||||||
|
return error(400, "INVITATION_VERIFICATION_REQUIRED", "缺少邀请凭证或微信手机号授权码");
|
||||||
|
}
|
||||||
|
var phoneResult = wechatMiniProgramService.exchangePhoneCode(params.get("phoneCode"));
|
||||||
|
if (!phoneResult.isOk()) {
|
||||||
|
return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage());
|
||||||
|
}
|
||||||
|
String openid = null;
|
||||||
|
String unionid = null;
|
||||||
|
String loginCode = params.get("loginCode");
|
||||||
|
if (loginCode != null && !loginCode.isBlank()) {
|
||||||
|
var identity = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||||
|
if (identity.isOk()) {
|
||||||
|
openid = identity.openid();
|
||||||
|
unionid = identity.unionid();
|
||||||
|
} else {
|
||||||
|
log.debug("员工接受邀请时 openid 沉淀跳过: {}", identity.errorMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> data = invitationService.acceptVerified(
|
||||||
|
params.get("token"), phoneResult.phone(), openid, unionid
|
||||||
|
);
|
||||||
|
return Map.of("code", 200, "message", "已加入门店", "data", data);
|
||||||
|
} catch (FlowException e) {
|
||||||
|
return error(e.code(), e.bizCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(Object value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer integerOrNull(Object value) {
|
||||||
|
return value == null || value.toString().isBlank() ? null : Integer.valueOf(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> error(int code, String bizCode, String message) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("code", code);
|
||||||
|
result.put("bizCode", bizCode);
|
||||||
|
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import com.petstore.auth.CurrentUser;
|
|||||||
import com.petstore.auth.CurrentUserContext;
|
import com.petstore.auth.CurrentUserContext;
|
||||||
import com.petstore.entity.Store;
|
import com.petstore.entity.Store;
|
||||||
import com.petstore.service.StoreService;
|
import com.petstore.service.StoreService;
|
||||||
|
import com.petstore.service.AuditLogService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@ -18,15 +19,15 @@ import java.util.stream.Collectors;
|
|||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class StoreController {
|
public class StoreController {
|
||||||
private final StoreService storeService;
|
private final StoreService storeService;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public Map<String, Object> register(@RequestBody Store store) {
|
public Map<String, Object> register(@RequestBody Store store) {
|
||||||
Store created = storeService.create(store);
|
return Map.of(
|
||||||
Map<String, Object> result = new HashMap<>();
|
"code", 410,
|
||||||
result.put("code", 200);
|
"message", "旧门店创建入口已停用,请使用微信核验手机号完成门店开通",
|
||||||
result.put("message", "注册成功");
|
"bizCode", "LEGACY_STORE_REGISTRATION_DISABLED"
|
||||||
result.put("data", created);
|
);
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -100,6 +101,15 @@ public class StoreController {
|
|||||||
if (updated == null) {
|
if (updated == null) {
|
||||||
return Map.of("code", 404, "message", "店铺不存在");
|
return Map.of("code", 404, "message", "店铺不存在");
|
||||||
}
|
}
|
||||||
|
boolean profileChanged = store.getName() != null || store.getPhone() != null || store.getAddress() != null
|
||||||
|
|| store.getIntro() != null || store.getLogo() != null
|
||||||
|
|| store.getLatitude() != null || store.getLongitude() != null;
|
||||||
|
boolean bookingChanged = store.getBookingDayStart() != null || store.getBookingLastSlotStart() != null
|
||||||
|
|| store.getBookingCapacity() != null;
|
||||||
|
auditLogService.record(
|
||||||
|
u.storeId(), u.userId(), u.role(), "store_settings_updated", "store", u.storeId().toString(),
|
||||||
|
"success", Map.of("profileChanged", profileChanged, "bookingChanged", bookingChanged)
|
||||||
|
);
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("code", 200);
|
result.put("code", 200);
|
||||||
result.put("message", "更新成功");
|
result.put("message", "更新成功");
|
||||||
@ -127,8 +137,7 @@ public class StoreController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按邀请码查门店:仅 boss/staff 可查,且邀请码对应的门店必须为当前用户所属门店。
|
* legacy 永久邀请码不再作为员工凭证;保留入口只为明确返回迁移提示。
|
||||||
* 新员工注册预览门店请走公开接口 {@code POST /api/user/register-staff}(请求体含 inviteCode,服务端内部校验)。
|
|
||||||
*/
|
*/
|
||||||
@GetMapping("/invite-code")
|
@GetMapping("/invite-code")
|
||||||
public Map<String, Object> getByInviteCode(@RequestParam String code) {
|
public Map<String, Object> getByInviteCode(@RequestParam String code) {
|
||||||
@ -136,16 +145,10 @@ public class StoreController {
|
|||||||
if (!u.isStoreUser() || u.storeId() == null) {
|
if (!u.isStoreUser() || u.storeId() == null) {
|
||||||
return Map.of("code", 403, "message", "仅门店员工可按邀请码查询", "bizCode", "FORBIDDEN");
|
return Map.of("code", 403, "message", "仅门店员工可按邀请码查询", "bizCode", "FORBIDDEN");
|
||||||
}
|
}
|
||||||
Store store = storeService.findByInviteCode(code);
|
return Map.of(
|
||||||
Map<String, Object> result = new HashMap<>();
|
"code", 410,
|
||||||
if (store == null) {
|
"message", "永久邀请码已停用,请使用限时员工邀请",
|
||||||
return Map.of("code", 404, "message", "邀请码无效");
|
"bizCode", "LEGACY_INVITE_DISABLED"
|
||||||
}
|
);
|
||||||
if (!store.getId().equals(u.storeId())) {
|
|
||||||
return Map.of("code", 403, "message", "邀请码不属于本店", "bizCode", "FORBIDDEN");
|
|
||||||
}
|
|
||||||
result.put("code", 200);
|
|
||||||
result.put("data", store);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.petstore.auth.CurrentUserContext;
|
|||||||
import com.petstore.entity.User;
|
import com.petstore.entity.User;
|
||||||
import com.petstore.service.UserService;
|
import com.petstore.service.UserService;
|
||||||
import com.petstore.service.WechatMiniProgramService;
|
import com.petstore.service.WechatMiniProgramService;
|
||||||
|
import com.petstore.service.AuditLogService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -21,15 +22,16 @@ import java.util.Map;
|
|||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final WechatMiniProgramService wechatMiniProgramService;
|
private final WechatMiniProgramService wechatMiniProgramService;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
/** 老板注册店铺 */
|
/** 老板注册店铺 */
|
||||||
@PostMapping("/register-boss")
|
@PostMapping("/register-boss")
|
||||||
public Map<String, Object> registerBoss(@RequestBody Map<String, String> params) {
|
public Map<String, Object> registerBoss(@RequestBody Map<String, String> params) {
|
||||||
String storeName = params.get("storeName");
|
return Map.of(
|
||||||
String bossName = params.get("bossName");
|
"code", 410,
|
||||||
String phone = params.get("phone");
|
"message", "旧注册入口已停用,请使用微信核验手机号完成门店开通",
|
||||||
String password = params.get("password");
|
"bizCode", "LEGACY_REGISTRATION_DISABLED"
|
||||||
return userService.registerBoss(storeName, bossName, phone, password);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录(老板/员工) */
|
/** 登录(老板/员工) */
|
||||||
@ -98,11 +100,11 @@ public class UserController {
|
|||||||
/** 员工注册(邀请码方式) */
|
/** 员工注册(邀请码方式) */
|
||||||
@PostMapping("/register-staff")
|
@PostMapping("/register-staff")
|
||||||
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {
|
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {
|
||||||
String phone = params.get("phone");
|
return Map.of(
|
||||||
String password = params.get("password");
|
"code", 410,
|
||||||
String name = params.get("name");
|
"message", "永久邀请码注册已停用,请让门店老板重新发送限时邀请",
|
||||||
String inviteCode = params.get("inviteCode");
|
"bizCode", "LEGACY_INVITE_DISABLED"
|
||||||
return userService.registerStaff(phone, password, name, inviteCode);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 老板:创建员工;storeId 从上下文派生,仅 boss。 */
|
/** 老板:创建员工;storeId 从上下文派生,仅 boss。 */
|
||||||
@ -112,9 +114,11 @@ public class UserController {
|
|||||||
if (!u.isBoss() || u.storeId() == null) {
|
if (!u.isBoss() || u.storeId() == null) {
|
||||||
return Map.of("code", 403, "message", "仅老板可创建员工", "bizCode", "FORBIDDEN");
|
return Map.of("code", 403, "message", "仅老板可创建员工", "bizCode", "FORBIDDEN");
|
||||||
}
|
}
|
||||||
String name = params.get("name").toString();
|
return Map.of(
|
||||||
String phone = params.get("phone").toString();
|
"code", 410,
|
||||||
return userService.createStaff(u.storeId(), name, phone);
|
"message", "直接创建员工已停用,请创建限时员工邀请",
|
||||||
|
"bizCode", "DIRECT_STAFF_CREATION_DISABLED"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 老板/员工:员工列表;storeId 从上下文派生。 */
|
/** 老板/员工:员工列表;storeId 从上下文派生。 */
|
||||||
@ -141,11 +145,18 @@ public class UserController {
|
|||||||
if (target == null || target.getStoreId() == null || !target.getStoreId().equals(u.storeId())) {
|
if (target == null || target.getStoreId() == null || !target.getStoreId().equals(u.storeId())) {
|
||||||
return Map.of("code", 403, "message", "无权删除他店员工", "bizCode", "FORBIDDEN");
|
return Map.of("code", 403, "message", "无权删除他店员工", "bizCode", "FORBIDDEN");
|
||||||
}
|
}
|
||||||
|
if (!"staff".equals(target.getRole())) {
|
||||||
|
return Map.of("code", 403, "message", "只能删除员工账号", "bizCode", "FORBIDDEN");
|
||||||
|
}
|
||||||
boolean ok = userService.deleteStaff(staffId);
|
boolean ok = userService.deleteStaff(staffId);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return Map.of("code", 404, "message", "员工不存在");
|
return Map.of("code", 404, "message", "员工不存在");
|
||||||
}
|
}
|
||||||
return Map.of("code", 200, "message", "删除成功");
|
auditLogService.record(
|
||||||
|
u.storeId(), u.userId(), u.role(), "staff_account_removed", "user", staffId.toString(),
|
||||||
|
"success", Map.of("removedRole", "staff")
|
||||||
|
);
|
||||||
|
return Map.of("code", 200, "message", "删除成功,员工现有会话已失效");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前登录用户信息(userId 从上下文派生,不信任 query)。 */
|
/** 获取当前登录用户信息(userId 从上下文派生,不信任 query)。 */
|
||||||
|
|||||||
68
src/main/java/com/petstore/entity/AuditLog.java
Normal file
68
src/main/java/com/petstore/entity/AuditLog.java
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package com.petstore.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不可变操作审计。只记录安全、权限和门店配置类操作,不承载业务对象当前状态。
|
||||||
|
* metadataJson 只能由服务端白名单字段构造,禁止写手机号、凭证、请求体和第三方身份标识。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "t_audit_log",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "uk_audit_log_audit_id", columnNames = "audit_id"),
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_audit_store_time", columnList = "store_id,occurred_at"),
|
||||||
|
@Index(name = "idx_audit_action_time", columnList = "action,occurred_at"),
|
||||||
|
@Index(name = "idx_audit_target", columnList = "target_type,target_id")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class AuditLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "audit_id", nullable = false, length = 36)
|
||||||
|
private String auditId;
|
||||||
|
|
||||||
|
@Column(name = "store_id", nullable = false)
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
@Column(name = "actor_user_id")
|
||||||
|
private Long actorUserId;
|
||||||
|
|
||||||
|
@Column(name = "actor_role", length = 16)
|
||||||
|
private String actorRole;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
@Column(name = "target_type", nullable = false, length = 32)
|
||||||
|
private String targetType;
|
||||||
|
|
||||||
|
@Column(name = "target_id", length = 64)
|
||||||
|
private String targetId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private String outcome;
|
||||||
|
|
||||||
|
@Column(name = "metadata_json", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String metadataJson;
|
||||||
|
|
||||||
|
@Column(name = "occurred_at", nullable = false)
|
||||||
|
private LocalDateTime occurredAt;
|
||||||
|
|
||||||
|
@Column(name = "create_time", nullable = false)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
80
src/main/java/com/petstore/entity/StaffInvitation.java
Normal file
80
src/main/java/com/petstore/entity/StaffInvitation.java
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package com.petstore.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 老板签发的限时、一次性员工邀请。原始 token 不落库,只保存 SHA-256 摘要。 */
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "t_staff_invitation",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_staff_invitation_public_id", columnNames = "invitation_id"),
|
||||||
|
@UniqueConstraint(name = "uk_staff_invitation_token_hash", columnNames = "token_hash")
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_staff_invite_store_status", columnList = "store_id,status,expires_at"),
|
||||||
|
@Index(name = "idx_staff_invite_phone", columnList = "store_id,invited_phone,status")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class StaffInvitation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "invitation_id", nullable = false, length = 36)
|
||||||
|
private String invitationId;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
@Column(name = "token_hash", nullable = false, length = 64)
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "store_id", nullable = false)
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
@Column(name = "invited_name", nullable = false, length = 64)
|
||||||
|
private String invitedName;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
@Column(name = "invited_phone", nullable = false, length = 20)
|
||||||
|
private String invitedPhone;
|
||||||
|
|
||||||
|
/** pending / accepted / revoked / expired */
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "created_by_user_id", nullable = false)
|
||||||
|
private Long createdByUserId;
|
||||||
|
|
||||||
|
@Column(name = "accepted_by_user_id")
|
||||||
|
private Long acceptedByUserId;
|
||||||
|
|
||||||
|
@Column(name = "accepted_at")
|
||||||
|
private LocalDateTime acceptedAt;
|
||||||
|
|
||||||
|
@Column(name = "revoked_by_user_id")
|
||||||
|
private Long revokedByUserId;
|
||||||
|
|
||||||
|
@Column(name = "revoked_at")
|
||||||
|
private LocalDateTime revokedAt;
|
||||||
|
|
||||||
|
@Column(name = "create_time", nullable = false)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@Column(name = "update_time", nullable = false)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@ -53,6 +53,16 @@ public class Store {
|
|||||||
/** 同一半小时内可并行服务的顾客数,默认 1。 */
|
/** 同一半小时内可并行服务的顾客数,默认 1。 */
|
||||||
@Column(name = "booking_capacity", nullable = false)
|
@Column(name = "booking_capacity", nullable = false)
|
||||||
private Integer bookingCapacity;
|
private Integer bookingCapacity;
|
||||||
|
|
||||||
|
/** unknown(历史)/ in_progress / completed。 */
|
||||||
|
@Column(name = "onboarding_status", nullable = false, length = 16)
|
||||||
|
private String onboardingStatus = "in_progress";
|
||||||
|
|
||||||
|
@Column(name = "onboarding_completed_at")
|
||||||
|
private LocalDateTime onboardingCompletedAt;
|
||||||
|
|
||||||
|
@Column(name = "onboarding_completed_by_user_id")
|
||||||
|
private Long onboardingCompletedByUserId;
|
||||||
|
|
||||||
@Column(name = "create_time")
|
@Column(name = "create_time")
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|||||||
7
src/main/java/com/petstore/mapper/AuditLogMapper.java
Normal file
7
src/main/java/com/petstore/mapper/AuditLogMapper.java
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import com.petstore.entity.AuditLog;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AuditLogMapper extends JpaRepository<AuditLog, Long> {
|
||||||
|
}
|
||||||
31
src/main/java/com/petstore/mapper/StaffInvitationMapper.java
Normal file
31
src/main/java/com/petstore/mapper/StaffInvitationMapper.java
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import com.petstore.entity.StaffInvitation;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface StaffInvitationMapper extends JpaRepository<StaffInvitation, Long> {
|
||||||
|
|
||||||
|
Optional<StaffInvitation> findFirstByTokenHash(String tokenHash);
|
||||||
|
|
||||||
|
List<StaffInvitation> findByStoreIdOrderByCreateTimeDesc(Long storeId);
|
||||||
|
|
||||||
|
List<StaffInvitation> findByStoreIdAndInvitedPhoneAndStatus(Long storeId, String invitedPhone, String status);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT i FROM StaffInvitation i WHERE i.tokenHash = :tokenHash")
|
||||||
|
Optional<StaffInvitation> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("SELECT i FROM StaffInvitation i WHERE i.invitationId = :invitationId AND i.storeId = :storeId")
|
||||||
|
Optional<StaffInvitation> findByInvitationIdAndStoreIdForUpdate(
|
||||||
|
@Param("invitationId") String invitationId,
|
||||||
|
@Param("storeId") Long storeId
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/main/java/com/petstore/service/AuditLogService.java
Normal file
101
src/main/java/com/petstore/service/AuditLogService.java
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.entity.AuditLog;
|
||||||
|
import com.petstore.mapper.AuditLogMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 统一写入低敏、不可变的操作审计。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuditLogService {
|
||||||
|
|
||||||
|
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
||||||
|
"phone", "token", "url", "openid", "unionid", "password", "secret",
|
||||||
|
"remark", "content", "request", "payload", "address", "latitude", "longitude"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final AuditLogMapper auditLogMapper;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public AuditLog record(
|
||||||
|
Long storeId,
|
||||||
|
Long actorUserId,
|
||||||
|
String actorRole,
|
||||||
|
String action,
|
||||||
|
String targetType,
|
||||||
|
String targetId,
|
||||||
|
String outcome,
|
||||||
|
Map<String, Object> metadata
|
||||||
|
) {
|
||||||
|
if (storeId == null) {
|
||||||
|
throw new IllegalArgumentException("审计记录必须归属门店");
|
||||||
|
}
|
||||||
|
AuditLog log = new AuditLog();
|
||||||
|
log.setAuditId(UUID.randomUUID().toString());
|
||||||
|
log.setStoreId(storeId);
|
||||||
|
log.setActorUserId(actorUserId);
|
||||||
|
log.setActorRole(normalizeOptional(actorRole, 16));
|
||||||
|
log.setAction(normalizeRequired(action, 64));
|
||||||
|
log.setTargetType(normalizeRequired(targetType, 32));
|
||||||
|
log.setTargetId(normalizeOptional(targetId, 64));
|
||||||
|
log.setOutcome(normalizeRequired(outcome == null ? "success" : outcome, 16));
|
||||||
|
log.setMetadataJson(serializeMetadata(metadata));
|
||||||
|
log.setOccurredAt(LocalDateTime.now());
|
||||||
|
log.setCreateTime(LocalDateTime.now());
|
||||||
|
return auditLogMapper.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serializeMetadata(Map<String, Object> metadata) {
|
||||||
|
Map<String, Object> safe = new LinkedHashMap<>();
|
||||||
|
if (metadata != null) {
|
||||||
|
for (Map.Entry<String, Object> entry : metadata.entrySet()) {
|
||||||
|
String key = entry.getKey() == null ? "" : entry.getKey().trim();
|
||||||
|
String normalizedKey = key.toLowerCase(Locale.ROOT).replace("_", "");
|
||||||
|
boolean forbidden = FORBIDDEN_METADATA_FRAGMENTS.stream().anyMatch(normalizedKey::contains)
|
||||||
|
|| "ip".equals(normalizedKey);
|
||||||
|
if (key.isBlank() || forbidden) {
|
||||||
|
throw new IllegalArgumentException("审计 metadata 包含禁止字段: " + key);
|
||||||
|
}
|
||||||
|
Object value = entry.getValue();
|
||||||
|
if (value == null || value instanceof Boolean || value instanceof Number) {
|
||||||
|
safe.put(key, value);
|
||||||
|
} else if (value instanceof String text) {
|
||||||
|
safe.put(key, text.length() <= 100 ? text : text.substring(0, 100));
|
||||||
|
} else {
|
||||||
|
throw new IllegalArgumentException("审计 metadata 仅允许标量值");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(safe);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("审计 metadata 无法序列化", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeRequired(String value, int maxLength) {
|
||||||
|
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("审计必填字段为空");
|
||||||
|
}
|
||||||
|
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeOptional(String value, int maxLength) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import com.petstore.entity.BusinessEvent;
|
|||||||
import com.petstore.entity.Report;
|
import com.petstore.entity.Report;
|
||||||
import com.petstore.entity.ReportLead;
|
import com.petstore.entity.ReportLead;
|
||||||
import com.petstore.entity.StoreCustomer;
|
import com.petstore.entity.StoreCustomer;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
import com.petstore.mapper.BusinessEventMapper;
|
import com.petstore.mapper.BusinessEventMapper;
|
||||||
import com.petstore.mapper.ReportMapper;
|
import com.petstore.mapper.ReportMapper;
|
||||||
import com.petstore.mapper.StoreCustomerMapper;
|
import com.petstore.mapper.StoreCustomerMapper;
|
||||||
@ -35,6 +36,7 @@ public class BusinessEventService {
|
|||||||
public static final String REPORT_OPENED = "report_opened";
|
public static final String REPORT_OPENED = "report_opened";
|
||||||
public static final String REPORT_REOPENED = "report_reopened";
|
public static final String REPORT_REOPENED = "report_reopened";
|
||||||
public static final String LEAD_SUBMITTED = "lead_submitted";
|
public static final String LEAD_SUBMITTED = "lead_submitted";
|
||||||
|
public static final String STORE_REGISTERED = "store_registered";
|
||||||
|
|
||||||
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
||||||
"phone", "token", "url", "openid", "unionid",
|
"phone", "token", "url", "openid", "unionid",
|
||||||
@ -46,6 +48,23 @@ public class BusinessEventService {
|
|||||||
private final ReportMapper reportMapper;
|
private final ReportMapper reportMapper;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BusinessEvent recordStoreRegistered(Store store, Long actorUserId) {
|
||||||
|
return record(new EventCommand(
|
||||||
|
STORE_REGISTERED,
|
||||||
|
store.getId(),
|
||||||
|
null,
|
||||||
|
"store",
|
||||||
|
store.getId(),
|
||||||
|
actorUserId,
|
||||||
|
"boss",
|
||||||
|
"admin",
|
||||||
|
store.getCreateTime(),
|
||||||
|
"store_registered:" + store.getId(),
|
||||||
|
Map.of()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BusinessEvent recordAppointmentCreated(
|
public BusinessEvent recordAppointmentCreated(
|
||||||
Appointment appointment,
|
Appointment appointment,
|
||||||
|
|||||||
21
src/main/java/com/petstore/service/FlowException.java
Normal file
21
src/main/java/com/petstore/service/FlowException.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
/** 面向 API 的可预期流程错误;不用于系统异常。 */
|
||||||
|
public class FlowException extends RuntimeException {
|
||||||
|
private final int code;
|
||||||
|
private final String bizCode;
|
||||||
|
|
||||||
|
public FlowException(int code, String bizCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.bizCode = bizCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String bizCode() {
|
||||||
|
return bizCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.auth.SessionTokenService;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 通过微信核验手机号创建门店与老板账号,替代无验证的 legacy 注册接口。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MerchantOnboardingService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final StoreMapper storeMapper;
|
||||||
|
private final SessionTokenService sessionTokenService;
|
||||||
|
private final BusinessEventService businessEventService;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> registerVerifiedBoss(
|
||||||
|
String storeName,
|
||||||
|
String bossName,
|
||||||
|
String verifiedPhone,
|
||||||
|
String wechatOpenid,
|
||||||
|
String wechatUnionid
|
||||||
|
) {
|
||||||
|
String storeNameNorm = required(storeName, 64, "请填写门店名称");
|
||||||
|
String bossNameNorm = required(bossName, 64, "请填写老板姓名");
|
||||||
|
if (verifiedPhone == null || !verifiedPhone.matches("^1[3-9]\\d{9}$")) {
|
||||||
|
throw new FlowException(400, "INVALID_PHONE", "微信手机号格式不正确");
|
||||||
|
}
|
||||||
|
if (userMapper.findByPhoneAndDeletedFalse(verifiedPhone) != null) {
|
||||||
|
throw new FlowException(409, "PHONE_ALREADY_REGISTERED", "该手机号已有账号,请直接登录");
|
||||||
|
}
|
||||||
|
assertWechatIdentityAvailable(wechatOpenid, wechatUnionid);
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
Store store = new Store();
|
||||||
|
store.setName(storeNameNorm);
|
||||||
|
store.setPhone(verifiedPhone);
|
||||||
|
store.setOwnerId(0L);
|
||||||
|
// legacy 兼容字段,不再作为员工注册凭证,也不返回客户端。
|
||||||
|
store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase());
|
||||||
|
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||||
|
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||||
|
store.setBookingCapacity(1);
|
||||||
|
store.setOnboardingStatus("in_progress");
|
||||||
|
store.setCreateTime(now);
|
||||||
|
store.setUpdateTime(now);
|
||||||
|
store.setDeleted(false);
|
||||||
|
store = storeMapper.save(store);
|
||||||
|
|
||||||
|
User boss = new User();
|
||||||
|
boss.setUsername(verifiedPhone);
|
||||||
|
boss.setName(bossNameNorm);
|
||||||
|
boss.setPhone(verifiedPhone);
|
||||||
|
boss.setPassword("wx_no_password");
|
||||||
|
boss.setStoreId(store.getId());
|
||||||
|
boss.setRole("boss");
|
||||||
|
boss.setWechatOpenid(blankToNull(wechatOpenid));
|
||||||
|
boss.setWechatUnionid(blankToNull(wechatUnionid));
|
||||||
|
boss.setCreateTime(now);
|
||||||
|
boss.setUpdateTime(now);
|
||||||
|
boss.setDeleted(false);
|
||||||
|
boss = userMapper.save(boss);
|
||||||
|
|
||||||
|
store.setOwnerId(boss.getId());
|
||||||
|
store = storeMapper.save(store);
|
||||||
|
|
||||||
|
businessEventService.recordStoreRegistered(store, boss.getId());
|
||||||
|
auditLogService.record(
|
||||||
|
store.getId(), boss.getId(), "boss", "store_registered", "store", store.getId().toString(),
|
||||||
|
"success", Map.of("verificationMethod", "wechat_phone", "onboardingStatus", "in_progress")
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> userView = new LinkedHashMap<>();
|
||||||
|
userView.put("id", boss.getId());
|
||||||
|
userView.put("name", boss.getName());
|
||||||
|
userView.put("phone", boss.getPhone());
|
||||||
|
userView.put("storeId", boss.getStoreId());
|
||||||
|
userView.put("role", boss.getRole());
|
||||||
|
|
||||||
|
Map<String, Object> storeView = new LinkedHashMap<>();
|
||||||
|
storeView.put("id", store.getId());
|
||||||
|
storeView.put("name", store.getName());
|
||||||
|
storeView.put("phone", store.getPhone());
|
||||||
|
storeView.put("onboardingStatus", store.getOnboardingStatus());
|
||||||
|
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("user", userView);
|
||||||
|
data.put("store", storeView);
|
||||||
|
data.put("sessionToken", sessionTokenService.issue(boss.getId(), store.getId(), "boss"));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertWechatIdentityAvailable(String openid, String unionid) {
|
||||||
|
if (openid != null && !openid.isBlank() && userMapper.findByWechatOpenidAndDeletedFalse(openid).isPresent()) {
|
||||||
|
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信");
|
||||||
|
}
|
||||||
|
if (unionid != null && !unionid.isBlank() && userMapper.findByWechatUnionidAndDeletedFalse(unionid).isPresent()) {
|
||||||
|
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, int max, String message) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
throw new FlowException(400, "INVALID_ONBOARDING_INPUT", message);
|
||||||
|
}
|
||||||
|
return normalized.length() <= max ? normalized : normalized.substring(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
337
src/main/java/com/petstore/service/StaffInvitationService.java
Normal file
337
src/main/java/com/petstore/service/StaffInvitationService.java
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.auth.SessionTokenService;
|
||||||
|
import com.petstore.entity.StaffInvitation;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StaffInvitationMapper;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 限时、一次性、手机号绑定的员工邀请。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StaffInvitationService {
|
||||||
|
|
||||||
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
private static final Base64.Encoder TOKEN_ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||||
|
|
||||||
|
private final StaffInvitationMapper invitationMapper;
|
||||||
|
private final StoreMapper storeMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final SessionTokenService sessionTokenService;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> create(
|
||||||
|
Long storeId,
|
||||||
|
Long actorUserId,
|
||||||
|
String actorRole,
|
||||||
|
String invitedName,
|
||||||
|
String invitedPhone,
|
||||||
|
Integer validDays
|
||||||
|
) {
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在");
|
||||||
|
}
|
||||||
|
String name = required(invitedName, 64, "请填写员工姓名");
|
||||||
|
String phone = invitedPhone == null ? "" : invitedPhone.trim();
|
||||||
|
if (!phone.matches("^1[3-9]\\d{9}$")) {
|
||||||
|
throw new FlowException(400, "INVALID_PHONE", "请填写正确的员工手机号");
|
||||||
|
}
|
||||||
|
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
||||||
|
throw new FlowException(409, "ACCOUNT_ALREADY_EXISTS", "该手机号已有账号,不能重复邀请");
|
||||||
|
}
|
||||||
|
int days = validDays == null ? 7 : validDays;
|
||||||
|
if (days < 1 || days > 30) {
|
||||||
|
throw new FlowException(400, "INVALID_EXPIRY", "邀请有效期须为 1~30 天");
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
int superseded = 0;
|
||||||
|
for (StaffInvitation previous : invitationMapper
|
||||||
|
.findByStoreIdAndInvitedPhoneAndStatus(storeId, phone, "pending")) {
|
||||||
|
previous.setStatus(now.isAfter(previous.getExpiresAt()) ? "expired" : "revoked");
|
||||||
|
if ("revoked".equals(previous.getStatus())) {
|
||||||
|
previous.setRevokedByUserId(actorUserId);
|
||||||
|
previous.setRevokedAt(now);
|
||||||
|
superseded++;
|
||||||
|
}
|
||||||
|
previous.setUpdateTime(now);
|
||||||
|
invitationMapper.save(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] tokenBytes = new byte[32];
|
||||||
|
SECURE_RANDOM.nextBytes(tokenBytes);
|
||||||
|
String rawToken = TOKEN_ENCODER.encodeToString(tokenBytes);
|
||||||
|
|
||||||
|
StaffInvitation invitation = new StaffInvitation();
|
||||||
|
invitation.setInvitationId(UUID.randomUUID().toString());
|
||||||
|
invitation.setTokenHash(hashToken(rawToken));
|
||||||
|
invitation.setStoreId(storeId);
|
||||||
|
invitation.setInvitedName(name);
|
||||||
|
invitation.setInvitedPhone(phone);
|
||||||
|
invitation.setStatus("pending");
|
||||||
|
invitation.setExpiresAt(now.plusDays(days));
|
||||||
|
invitation.setCreatedByUserId(actorUserId);
|
||||||
|
invitation.setCreateTime(now);
|
||||||
|
invitation.setUpdateTime(now);
|
||||||
|
invitation = invitationMapper.save(invitation);
|
||||||
|
|
||||||
|
auditLogService.record(
|
||||||
|
storeId, actorUserId, actorRole, "staff_invitation_created", "staff_invitation",
|
||||||
|
invitation.getInvitationId(), "success",
|
||||||
|
Map.of("validDays", days, "supersededInvitationCount", superseded)
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> result = toView(invitation, store.getName());
|
||||||
|
result.put("inviteToken", rawToken);
|
||||||
|
result.put("invitePath", "/pages/login/Login?staffInvite=" + rawToken);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<Map<String, Object>> list(Long storeId) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在");
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (StaffInvitation invitation : invitationMapper.findByStoreIdOrderByCreateTimeDesc(storeId)) {
|
||||||
|
if ("pending".equals(invitation.getStatus()) && now.isAfter(invitation.getExpiresAt())) {
|
||||||
|
invitation.setStatus("expired");
|
||||||
|
invitation.setUpdateTime(now);
|
||||||
|
invitationMapper.save(invitation);
|
||||||
|
}
|
||||||
|
result.add(toView(invitation, store.getName()));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> preview(String rawToken) {
|
||||||
|
StaffInvitation invitation = invitationMapper.findFirstByTokenHash(hashToken(rawToken)).orElse(null);
|
||||||
|
if (invitation == null) {
|
||||||
|
throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效");
|
||||||
|
}
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(invitation.getStoreId()).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效");
|
||||||
|
}
|
||||||
|
Map<String, Object> result = toView(invitation, store.getName());
|
||||||
|
String status = effectiveStatus(invitation, LocalDateTime.now());
|
||||||
|
result.put("status", status);
|
||||||
|
// 已接受邀请只允许原账号再次核验同一微信手机号,幂等恢复登录态,不创建第二个账号。
|
||||||
|
result.put("canAccept", "pending".equals(status) || "accepted".equals(status));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> revoke(
|
||||||
|
Long storeId,
|
||||||
|
Long actorUserId,
|
||||||
|
String actorRole,
|
||||||
|
String invitationId
|
||||||
|
) {
|
||||||
|
StaffInvitation invitation = invitationMapper
|
||||||
|
.findByInvitationIdAndStoreIdForUpdate(invitationId, storeId)
|
||||||
|
.orElse(null);
|
||||||
|
if (invitation == null) {
|
||||||
|
throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在");
|
||||||
|
}
|
||||||
|
String status = effectiveStatus(invitation, LocalDateTime.now());
|
||||||
|
if ("accepted".equals(status)) {
|
||||||
|
throw new FlowException(409, "INVITATION_ALREADY_ACCEPTED", "员工已加入,不能撤销邀请");
|
||||||
|
}
|
||||||
|
boolean alreadyRevoked = "revoked".equals(status);
|
||||||
|
if (!alreadyRevoked) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
invitation.setStatus("expired".equals(status) ? "expired" : "revoked");
|
||||||
|
if ("revoked".equals(invitation.getStatus())) {
|
||||||
|
invitation.setRevokedByUserId(actorUserId);
|
||||||
|
invitation.setRevokedAt(now);
|
||||||
|
}
|
||||||
|
invitation.setUpdateTime(now);
|
||||||
|
invitation = invitationMapper.save(invitation);
|
||||||
|
auditLogService.record(
|
||||||
|
storeId, actorUserId, actorRole, "staff_invitation_revoked", "staff_invitation",
|
||||||
|
invitation.getInvitationId(), "success", Map.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElseThrow();
|
||||||
|
Map<String, Object> result = toView(invitation, store.getName());
|
||||||
|
result.put("alreadyRevoked", alreadyRevoked);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> acceptVerified(
|
||||||
|
String rawToken,
|
||||||
|
String verifiedPhone,
|
||||||
|
String wechatOpenid,
|
||||||
|
String wechatUnionid
|
||||||
|
) {
|
||||||
|
StaffInvitation invitation = invitationMapper.findByTokenHashForUpdate(hashToken(rawToken)).orElse(null);
|
||||||
|
if (invitation == null) {
|
||||||
|
throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效");
|
||||||
|
}
|
||||||
|
String status = effectiveStatus(invitation, LocalDateTime.now());
|
||||||
|
if ("revoked".equals(status)) {
|
||||||
|
throw new FlowException(409, "INVITATION_REVOKED", "邀请已撤销,请联系门店老板重新邀请");
|
||||||
|
}
|
||||||
|
if ("expired".equals(status)) {
|
||||||
|
throw new FlowException(409, "INVITATION_EXPIRED", "邀请已过期,请联系门店老板重新邀请");
|
||||||
|
}
|
||||||
|
if (verifiedPhone == null || !verifiedPhone.equals(invitation.getInvitedPhone())) {
|
||||||
|
throw new FlowException(403, "INVITATION_PHONE_MISMATCH", "微信手机号与受邀手机号不一致");
|
||||||
|
}
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(invitation.getStoreId()).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "STORE_NOT_FOUND", "受邀门店不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("accepted".equals(status)) {
|
||||||
|
User accepted = invitation.getAcceptedByUserId() == null ? null
|
||||||
|
: userMapper.findByIdAndDeletedFalse(invitation.getAcceptedByUserId()).orElse(null);
|
||||||
|
if (accepted == null
|
||||||
|
|| !verifiedPhone.equals(accepted.getPhone())
|
||||||
|
|| !invitation.getStoreId().equals(accepted.getStoreId())
|
||||||
|
|| !"staff".equals(accepted.getRole())) {
|
||||||
|
throw new FlowException(409, "INVITATION_ALREADY_ACCEPTED", "邀请已被使用");
|
||||||
|
}
|
||||||
|
return loginView(accepted, store, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userMapper.findByPhoneAndDeletedFalse(verifiedPhone) != null) {
|
||||||
|
throw new FlowException(409, "ACCOUNT_ALREADY_EXISTS", "该手机号已有账号,不能接受员工邀请");
|
||||||
|
}
|
||||||
|
assertWechatIdentityAvailable(wechatOpenid, wechatUnionid);
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
User staff = new User();
|
||||||
|
staff.setUsername(verifiedPhone);
|
||||||
|
staff.setPhone(verifiedPhone);
|
||||||
|
staff.setName(invitation.getInvitedName());
|
||||||
|
staff.setPassword("wx_no_password");
|
||||||
|
staff.setStoreId(invitation.getStoreId());
|
||||||
|
staff.setRole("staff");
|
||||||
|
staff.setWechatOpenid(blankToNull(wechatOpenid));
|
||||||
|
staff.setWechatUnionid(blankToNull(wechatUnionid));
|
||||||
|
staff.setCreateTime(now);
|
||||||
|
staff.setUpdateTime(now);
|
||||||
|
staff.setDeleted(false);
|
||||||
|
staff = userMapper.save(staff);
|
||||||
|
|
||||||
|
invitation.setStatus("accepted");
|
||||||
|
invitation.setAcceptedByUserId(staff.getId());
|
||||||
|
invitation.setAcceptedAt(now);
|
||||||
|
invitation.setUpdateTime(now);
|
||||||
|
invitationMapper.save(invitation);
|
||||||
|
|
||||||
|
auditLogService.record(
|
||||||
|
invitation.getStoreId(), staff.getId(), "staff", "staff_invitation_accepted", "staff_invitation",
|
||||||
|
invitation.getInvitationId(), "success",
|
||||||
|
Map.of("verificationMethod", "wechat_phone", "accountRole", "staff")
|
||||||
|
);
|
||||||
|
return loginView(staff, store, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> loginView(User user, Store store, boolean alreadyAccepted) {
|
||||||
|
Map<String, Object> userView = new LinkedHashMap<>();
|
||||||
|
userView.put("id", user.getId());
|
||||||
|
userView.put("name", user.getName());
|
||||||
|
userView.put("phone", user.getPhone());
|
||||||
|
userView.put("storeId", user.getStoreId());
|
||||||
|
userView.put("role", user.getRole());
|
||||||
|
|
||||||
|
Map<String, Object> storeView = new LinkedHashMap<>();
|
||||||
|
storeView.put("id", store.getId());
|
||||||
|
storeView.put("name", store.getName());
|
||||||
|
storeView.put("phone", store.getPhone());
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("user", userView);
|
||||||
|
result.put("store", storeView);
|
||||||
|
result.put("sessionToken", sessionTokenService.issue(user.getId(), user.getStoreId(), user.getRole()));
|
||||||
|
result.put("alreadyAccepted", alreadyAccepted);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertWechatIdentityAvailable(String openid, String unionid) {
|
||||||
|
if (openid != null && !openid.isBlank() && userMapper.findByWechatOpenidAndDeletedFalse(openid).isPresent()) {
|
||||||
|
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请使用受邀员工本人的微信");
|
||||||
|
}
|
||||||
|
if (unionid != null && !unionid.isBlank() && userMapper.findByWechatUnionidAndDeletedFalse(unionid).isPresent()) {
|
||||||
|
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请使用受邀员工本人的微信");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> toView(StaffInvitation invitation, String storeName) {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("invitationId", invitation.getInvitationId());
|
||||||
|
result.put("storeName", storeName);
|
||||||
|
result.put("invitedName", invitation.getInvitedName());
|
||||||
|
result.put("maskedPhone", maskPhone(invitation.getInvitedPhone()));
|
||||||
|
result.put("status", effectiveStatus(invitation, LocalDateTime.now()));
|
||||||
|
result.put("expiresAt", invitation.getExpiresAt());
|
||||||
|
result.put("acceptedAt", invitation.getAcceptedAt());
|
||||||
|
result.put("createdAt", invitation.getCreateTime());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String effectiveStatus(StaffInvitation invitation, LocalDateTime now) {
|
||||||
|
if ("pending".equals(invitation.getStatus())
|
||||||
|
&& invitation.getExpiresAt() != null
|
||||||
|
&& now.isAfter(invitation.getExpiresAt())) {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
return invitation.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhone(String phone) {
|
||||||
|
if (phone == null || phone.length() < 7) return "***";
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, int max, String message) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
throw new FlowException(400, "INVALID_INVITATION", message);
|
||||||
|
}
|
||||||
|
return normalized.length() <= max ? normalized : normalized.substring(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String hashToken(String rawToken) {
|
||||||
|
String normalized = rawToken == null ? "" : rawToken.trim();
|
||||||
|
if (normalized.isBlank() || normalized.length() > 512) {
|
||||||
|
throw new FlowException(404, "INVITATION_NOT_FOUND", "邀请不存在或已失效");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(normalized.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return java.util.HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("运行环境不支持 SHA-256", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/main/java/com/petstore/service/StoreOnboardingService.java
Normal file
126
src/main/java/com/petstore/service/StoreOnboardingService.java
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.entity.ServiceType;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.ServiceTypeMapper;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** 门店开通就绪清单。完成状态必须由老板显式确认,历史门店保持 unknown。 */
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StoreOnboardingService {
|
||||||
|
|
||||||
|
private final StoreMapper storeMapper;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final ServiceTypeMapper serviceTypeMapper;
|
||||||
|
private final AuditLogService auditLogService;
|
||||||
|
|
||||||
|
public Map<String, Object> getStatus(Long storeId) {
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalse(storeId).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在");
|
||||||
|
}
|
||||||
|
return buildStatus(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> complete(Long storeId, Long actorUserId, String actorRole) {
|
||||||
|
Store store = storeMapper.findByIdAndDeletedFalseForUpdate(storeId).orElse(null);
|
||||||
|
if (store == null) {
|
||||||
|
throw new FlowException(404, "STORE_NOT_FOUND", "门店不存在");
|
||||||
|
}
|
||||||
|
Map<String, Object> status = buildStatus(store);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> missingSteps = (List<String>) status.get("missingRequiredSteps");
|
||||||
|
if (!missingSteps.isEmpty()) {
|
||||||
|
throw new FlowException(
|
||||||
|
409,
|
||||||
|
"ONBOARDING_NOT_READY",
|
||||||
|
"还有必填开通项未完成:" + String.join("、", missingSteps)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
boolean alreadyCompleted = "completed".equals(store.getOnboardingStatus());
|
||||||
|
if (!alreadyCompleted) {
|
||||||
|
store.setOnboardingStatus("completed");
|
||||||
|
store.setOnboardingCompletedAt(LocalDateTime.now());
|
||||||
|
store.setOnboardingCompletedByUserId(actorUserId);
|
||||||
|
store.setUpdateTime(LocalDateTime.now());
|
||||||
|
store = storeMapper.save(store);
|
||||||
|
auditLogService.record(
|
||||||
|
storeId, actorUserId, actorRole, "store_onboarding_completed", "store", storeId.toString(),
|
||||||
|
"success", Map.of("requiredStepCount", 3)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>(buildStatus(store));
|
||||||
|
result.put("alreadyCompleted", alreadyCompleted);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildStatus(Store store) {
|
||||||
|
boolean profileComplete = notBlank(store.getName()) && notBlank(store.getPhone()) && notBlank(store.getAddress());
|
||||||
|
boolean bookingComplete = store.getBookingDayStart() != null
|
||||||
|
&& store.getBookingLastSlotStart() != null
|
||||||
|
&& !store.getBookingLastSlotStart().isBefore(store.getBookingDayStart())
|
||||||
|
&& store.getBookingCapacity() != null
|
||||||
|
&& store.getBookingCapacity() >= 1
|
||||||
|
&& store.getBookingCapacity() <= 10;
|
||||||
|
List<ServiceType> serviceTypes = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(store.getId());
|
||||||
|
boolean serviceCatalogComplete = serviceTypes.stream().anyMatch(s -> s.getName() != null && !s.getName().isBlank());
|
||||||
|
List<User> members = userMapper.findByStoreIdAndDeletedFalse(store.getId());
|
||||||
|
long staffCount = members.stream().filter(u -> "staff".equals(u.getRole())).count();
|
||||||
|
|
||||||
|
List<Map<String, Object>> steps = new ArrayList<>();
|
||||||
|
steps.add(step("profile", "补全门店资料", true, profileComplete, "填写门店名称、电话和地址"));
|
||||||
|
steps.add(step("booking", "确认预约容量", true, bookingComplete, "确认营业号源范围与并发接待数"));
|
||||||
|
steps.add(step("services", "确认服务项目", true, serviceCatalogComplete, "至少保留一个可预约服务项目"));
|
||||||
|
steps.add(step("team", "邀请员工", false, staffCount > 0, "单人门店可稍后完成"));
|
||||||
|
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
if (!profileComplete) missing.add("门店资料");
|
||||||
|
if (!bookingComplete) missing.add("预约容量");
|
||||||
|
if (!serviceCatalogComplete) missing.add("服务项目");
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("status", normalizeStatus(store.getOnboardingStatus()));
|
||||||
|
result.put("completedAt", store.getOnboardingCompletedAt());
|
||||||
|
result.put("readyToComplete", missing.isEmpty());
|
||||||
|
result.put("missingRequiredSteps", missing);
|
||||||
|
result.put("completedStepCount", steps.stream().filter(row -> Boolean.TRUE.equals(row.get("completed"))).count());
|
||||||
|
result.put("totalStepCount", steps.size());
|
||||||
|
result.put("staffCount", staffCount);
|
||||||
|
result.put("steps", steps);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> step(String id, String title, boolean required, boolean completed, String hint) {
|
||||||
|
Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("id", id);
|
||||||
|
row.put("title", title);
|
||||||
|
row.put("required", required);
|
||||||
|
row.put("completed", completed);
|
||||||
|
row.put("hint", hint);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeStatus(String status) {
|
||||||
|
if ("completed".equals(status) || "in_progress".equals(status) || "unknown".equals(status)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean notBlank(String value) {
|
||||||
|
return value != null && !value.trim().isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,13 @@ public class StoreService {
|
|||||||
|
|
||||||
public Store create(Store store) {
|
public Store create(Store store) {
|
||||||
store.setBookingCapacity(normalizeCapacity(store.getBookingCapacity()));
|
store.setBookingCapacity(normalizeCapacity(store.getBookingCapacity()));
|
||||||
|
if (store.getBookingDayStart() == null) {
|
||||||
|
store.setBookingDayStart(StoreBookingWindow.DEFAULT_DAY_START);
|
||||||
|
}
|
||||||
|
if (store.getBookingLastSlotStart() == null) {
|
||||||
|
store.setBookingLastSlotStart(StoreBookingWindow.DEFAULT_LAST_SLOT_START);
|
||||||
|
}
|
||||||
|
store.setOnboardingStatus("in_progress");
|
||||||
store.setInviteCode(generateInviteCode());
|
store.setInviteCode(generateInviteCode());
|
||||||
store.setCreateTime(LocalDateTime.now());
|
store.setCreateTime(LocalDateTime.now());
|
||||||
store.setUpdateTime(LocalDateTime.now());
|
store.setUpdateTime(LocalDateTime.now());
|
||||||
@ -71,6 +78,12 @@ public class StoreService {
|
|||||||
existing.setLogo(incoming.getLogo());
|
existing.setLogo(incoming.getLogo());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 门店一旦完成开通,核心联系方式不得再被清空,否则会出现“已开通但不可运营”的脏状态。
|
||||||
|
if ("completed".equals(existing.getOnboardingStatus())
|
||||||
|
&& (isBlank(existing.getName()) || isBlank(existing.getPhone()) || isBlank(existing.getAddress()))) {
|
||||||
|
throw new IllegalArgumentException("已开通门店的名称、电话和地址不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
LocalTime start = existing.getBookingDayStart() != null
|
LocalTime start = existing.getBookingDayStart() != null
|
||||||
? StoreBookingWindow.snapHalfHour(existing.getBookingDayStart())
|
? StoreBookingWindow.snapHalfHour(existing.getBookingDayStart())
|
||||||
: StoreBookingWindow.DEFAULT_DAY_START;
|
: StoreBookingWindow.DEFAULT_DAY_START;
|
||||||
@ -125,4 +138,8 @@ public class StoreService {
|
|||||||
}
|
}
|
||||||
return capacity;
|
return capacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,44 +27,6 @@ public class UserService {
|
|||||||
@Value("${app.demo.sms-universal-code:}")
|
@Value("${app.demo.sms-universal-code:}")
|
||||||
private String universalSmsCode;
|
private String universalSmsCode;
|
||||||
|
|
||||||
public Map<String, Object> registerBoss(String storeName, String bossName, String phone, String password) {
|
|
||||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
|
||||||
return Map.of("code", 400, "message", "手机号已注册");
|
|
||||||
}
|
|
||||||
|
|
||||||
Store store = new Store();
|
|
||||||
store.setName(storeName);
|
|
||||||
store.setPhone(phone);
|
|
||||||
store.setOwnerId(0L);
|
|
||||||
store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase());
|
|
||||||
store.setCreateTime(LocalDateTime.now());
|
|
||||||
store.setUpdateTime(LocalDateTime.now());
|
|
||||||
store.setDeleted(false);
|
|
||||||
store = storeMapper.save(store);
|
|
||||||
|
|
||||||
User boss = new User();
|
|
||||||
boss.setUsername(phone);
|
|
||||||
boss.setName(bossName);
|
|
||||||
boss.setPhone(phone);
|
|
||||||
boss.setPassword(password);
|
|
||||||
boss.setStoreId(store.getId());
|
|
||||||
boss.setRole("boss");
|
|
||||||
boss.setCreateTime(LocalDateTime.now());
|
|
||||||
boss.setUpdateTime(LocalDateTime.now());
|
|
||||||
boss.setDeleted(false);
|
|
||||||
boss = userMapper.save(boss);
|
|
||||||
|
|
||||||
store.setOwnerId(boss.getId());
|
|
||||||
storeMapper.save(store);
|
|
||||||
|
|
||||||
Map<String, Object> data = new HashMap<>();
|
|
||||||
boss.setPassword(null);
|
|
||||||
data.put("user", boss);
|
|
||||||
data.put("store", store);
|
|
||||||
data.put("sessionToken", sessionTokenService.issue(boss.getId(), boss.getStoreId(), boss.getRole()));
|
|
||||||
return Map.of("code", 200, "message", "注册成功", "data", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> login(String phone, String code) {
|
public Map<String, Object> login(String phone, String code) {
|
||||||
// 生产登录策略:只走微信授权(wx-phone-login);SMS 验证码登录仅 dev 模式可用。
|
// 生产登录策略:只走微信授权(wx-phone-login);SMS 验证码登录仅 dev 模式可用。
|
||||||
// universalSmsCode 为空(production profile)= SMS 登录已关闭。
|
// universalSmsCode 为空(production profile)= SMS 登录已关闭。
|
||||||
@ -227,56 +189,6 @@ public class UserService {
|
|||||||
return userMapper.save(target);
|
return userMapper.save(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> registerStaff(String phone, String password, String name, String inviteCode) {
|
|
||||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
|
||||||
return Map.of("code", 400, "message", "手机号已注册");
|
|
||||||
}
|
|
||||||
Store store = storeMapper.findByInviteCodeAndDeletedFalse(inviteCode);
|
|
||||||
if (store == null) {
|
|
||||||
return Map.of("code", 400, "message", "邀请码无效");
|
|
||||||
}
|
|
||||||
User staff = new User();
|
|
||||||
staff.setUsername(phone);
|
|
||||||
staff.setPhone(phone);
|
|
||||||
staff.setPassword(password);
|
|
||||||
staff.setName(name);
|
|
||||||
staff.setStoreId(store.getId());
|
|
||||||
staff.setRole("staff");
|
|
||||||
staff.setCreateTime(LocalDateTime.now());
|
|
||||||
staff.setUpdateTime(LocalDateTime.now());
|
|
||||||
staff.setDeleted(false);
|
|
||||||
staff = userMapper.save(staff);
|
|
||||||
Map<String, Object> data = new HashMap<>();
|
|
||||||
staff.setPassword(null);
|
|
||||||
data.put("user", staff);
|
|
||||||
data.put("store", store);
|
|
||||||
data.put("sessionToken", sessionTokenService.issue(staff.getId(), staff.getStoreId(), staff.getRole()));
|
|
||||||
return Map.of("code", 200, "message", "注册成功", "data", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> createStaff(Long storeId, String name, String phone) {
|
|
||||||
if (storeId == null) {
|
|
||||||
return Map.of("code", 400, "message", "店铺ID不能为空");
|
|
||||||
}
|
|
||||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
|
||||||
return Map.of("code", 400, "message", "手机号已存在");
|
|
||||||
}
|
|
||||||
String pwd = String.format("%06d", (int)(Math.random() * 999999));
|
|
||||||
User staff = new User();
|
|
||||||
staff.setUsername(phone);
|
|
||||||
staff.setName(name);
|
|
||||||
staff.setPhone(phone);
|
|
||||||
staff.setPassword(pwd);
|
|
||||||
staff.setStoreId(storeId);
|
|
||||||
staff.setRole("staff");
|
|
||||||
staff.setCreateTime(LocalDateTime.now());
|
|
||||||
staff.setUpdateTime(LocalDateTime.now());
|
|
||||||
staff.setDeleted(false);
|
|
||||||
staff = userMapper.save(staff);
|
|
||||||
staff.setPassword(null);
|
|
||||||
return Map.of("code", 200, "message", "创建成功,初始密码:" + pwd, "data", staff);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<User> getStaffList(Long storeId) {
|
public List<User> getStaffList(Long storeId) {
|
||||||
return userMapper.findByStoreIdAndDeletedFalse(storeId);
|
return userMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.petstore.auth;
|
||||||
|
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthInterceptorActiveUserTest {
|
||||||
|
|
||||||
|
@Mock private SessionTokenService sessionTokenService;
|
||||||
|
@Mock private UserMapper userMapper;
|
||||||
|
@Mock private StoreMapper storeMapper;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clear() {
|
||||||
|
CurrentUserContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletedAccountImmediatelyInvalidatesSignedSession() throws Exception {
|
||||||
|
AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper);
|
||||||
|
MockHttpServletRequest request = protectedRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
when(sessionTokenService.verify("signed-token"))
|
||||||
|
.thenReturn(Optional.of(new CurrentUser(2L, 10L, "staff")));
|
||||||
|
when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||||
|
assertEquals(401, response.getStatus());
|
||||||
|
assertTrue(response.getContentAsString().contains("账号权限已变更"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roleOrStoreDriftInvalidatesOldSession() throws Exception {
|
||||||
|
AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper);
|
||||||
|
MockHttpServletRequest request = protectedRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
when(sessionTokenService.verify("signed-token"))
|
||||||
|
.thenReturn(Optional.of(new CurrentUser(2L, 10L, "staff")));
|
||||||
|
User active = new User();
|
||||||
|
active.setId(2L);
|
||||||
|
active.setStoreId(20L);
|
||||||
|
active.setRole("staff");
|
||||||
|
when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active));
|
||||||
|
|
||||||
|
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||||
|
assertEquals(401, response.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeAccountWithMatchingScopeIsAccepted() throws Exception {
|
||||||
|
AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper);
|
||||||
|
MockHttpServletRequest request = protectedRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
CurrentUser current = new CurrentUser(2L, 10L, "staff");
|
||||||
|
when(sessionTokenService.verify("signed-token")).thenReturn(Optional.of(current));
|
||||||
|
User active = new User();
|
||||||
|
active.setId(2L);
|
||||||
|
active.setStoreId(10L);
|
||||||
|
active.setRole("staff");
|
||||||
|
when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active));
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(new com.petstore.entity.Store()));
|
||||||
|
|
||||||
|
assertTrue(interceptor.preHandle(request, response, new Object()));
|
||||||
|
assertEquals(current, CurrentUserContext.require());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletedStoreImmediatelyInvalidatesStoreUserSession() throws Exception {
|
||||||
|
AuthInterceptor interceptor = new AuthInterceptor(sessionTokenService, userMapper, storeMapper);
|
||||||
|
MockHttpServletRequest request = protectedRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
CurrentUser current = new CurrentUser(2L, 10L, "staff");
|
||||||
|
when(sessionTokenService.verify("signed-token")).thenReturn(Optional.of(current));
|
||||||
|
User active = new User();
|
||||||
|
active.setId(2L);
|
||||||
|
active.setStoreId(10L);
|
||||||
|
active.setRole("staff");
|
||||||
|
when(userMapper.findByIdAndDeletedFalse(2L)).thenReturn(Optional.of(active));
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertFalse(interceptor.preHandle(request, response, new Object()));
|
||||||
|
assertEquals(401, response.getStatus());
|
||||||
|
assertTrue(response.getContentAsString().contains("门店已停用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MockHttpServletRequest protectedRequest() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/user/info");
|
||||||
|
request.addHeader("Authorization", "Bearer signed-token");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,12 +42,13 @@ class AdminStoreControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void staffGetsInviteCode() {
|
void staffGetsStoreWithoutLegacyInviteCode() {
|
||||||
CurrentUserContext.set(new CurrentUser(2L, 10L, "staff"));
|
CurrentUserContext.set(new CurrentUser(2L, 10L, "staff"));
|
||||||
Store store = new Store();
|
Store store = new Store();
|
||||||
store.setId(10L);
|
store.setId(10L);
|
||||||
store.setName("测试店");
|
store.setName("测试店");
|
||||||
store.setInviteCode("ABCD1234");
|
store.setInviteCode("ABCD1234");
|
||||||
|
store.setOnboardingStatus("in_progress");
|
||||||
store.setBookingDayStart(LocalTime.of(9, 0));
|
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||||
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||||
when(storeService.findById(10L)).thenReturn(store);
|
when(storeService.findById(10L)).thenReturn(store);
|
||||||
@ -56,7 +57,8 @@ class AdminStoreControllerTest {
|
|||||||
assertEquals(200, result.get("code"));
|
assertEquals(200, result.get("code"));
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||||
assertEquals("ABCD1234", data.get("inviteCode"));
|
assertTrue(!data.containsKey("inviteCode"));
|
||||||
|
assertEquals("in_progress", data.get("onboardingStatus"));
|
||||||
assertTrue(data.containsKey("bookingDayStart"));
|
assertTrue(data.containsKey("bookingDayStart"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.petstore.auth.CurrentUser;
|
|||||||
import com.petstore.auth.CurrentUserContext;
|
import com.petstore.auth.CurrentUserContext;
|
||||||
import com.petstore.entity.Store;
|
import com.petstore.entity.Store;
|
||||||
import com.petstore.service.StoreService;
|
import com.petstore.service.StoreService;
|
||||||
|
import com.petstore.service.AuditLogService;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@ -30,6 +31,7 @@ import static org.mockito.Mockito.*;
|
|||||||
class StoreControllerTest {
|
class StoreControllerTest {
|
||||||
|
|
||||||
@Mock private StoreService storeService;
|
@Mock private StoreService storeService;
|
||||||
|
@Mock private AuditLogService auditLogService;
|
||||||
|
|
||||||
@InjectMocks private StoreController controller;
|
@InjectMocks private StoreController controller;
|
||||||
|
|
||||||
@ -108,14 +110,12 @@ class StoreControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inviteCodeCrossStoreForbidden() {
|
void legacyInviteCodeDisabledEvenForCrossStoreCode() {
|
||||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||||
Store other = new Store();
|
|
||||||
other.setId(20L); // 他店
|
|
||||||
when(storeService.findByInviteCode("ABC12345")).thenReturn(other);
|
|
||||||
Map<String, Object> result = controller.getByInviteCode("ABC12345");
|
Map<String, Object> result = controller.getByInviteCode("ABC12345");
|
||||||
assertEquals(403, result.get("code"));
|
assertEquals(410, result.get("code"));
|
||||||
assertEquals("FORBIDDEN", result.get("bizCode"));
|
assertEquals("LEGACY_INVITE_DISABLED", result.get("bizCode"));
|
||||||
|
verifyNoInteractions(storeService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -127,13 +127,12 @@ class StoreControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inviteCodeSameStoreOk() {
|
void legacyInviteCodeDisabledForOwnStoreToo() {
|
||||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||||
Store own = new Store();
|
|
||||||
own.setId(10L);
|
|
||||||
when(storeService.findByInviteCode("ABC12345")).thenReturn(own);
|
|
||||||
Map<String, Object> result = controller.getByInviteCode("ABC12345");
|
Map<String, Object> result = controller.getByInviteCode("ABC12345");
|
||||||
assertEquals(200, result.get("code"));
|
assertEquals(410, result.get("code"));
|
||||||
|
assertEquals("LEGACY_INVITE_DISABLED", result.get("bizCode"));
|
||||||
|
verifyNoInteractions(storeService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class StoreOnboardingMigrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void migrationKeepsRawTokenOutOfDatabaseAndDefinesAllInvariants() throws Exception {
|
||||||
|
String sql = Files.readString(Path.of("db/migrations/20260802_create_store_onboarding.sql"));
|
||||||
|
|
||||||
|
assertTrue(sql.contains("token_hash VARCHAR(64) NOT NULL"));
|
||||||
|
assertTrue(sql.contains("UPDATE t_store\nSET onboarding_status = 'unknown'"));
|
||||||
|
assertTrue(sql.contains("t_staff_invitation"));
|
||||||
|
assertTrue(sql.contains("t_audit_log"));
|
||||||
|
assertTrue(sql.contains("invalid_store_onboarding_status_count"));
|
||||||
|
assertTrue(sql.contains("inconsistent_staff_invitation_acceptance_count"));
|
||||||
|
assertTrue(sql.contains("inconsistent_staff_invitation_revocation_count"));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/test/java/com/petstore/service/AuditLogServiceTest.java
Normal file
58
src/test/java/com/petstore/service/AuditLogServiceTest.java
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.entity.AuditLog;
|
||||||
|
import com.petstore.mapper.AuditLogMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuditLogServiceTest {
|
||||||
|
|
||||||
|
@Mock private AuditLogMapper auditLogMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistsOnlyWhitelistedScalarMetadata() throws Exception {
|
||||||
|
when(auditLogMapper.save(any(AuditLog.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
AuditLogService service = new AuditLogService(auditLogMapper, new ObjectMapper());
|
||||||
|
|
||||||
|
AuditLog saved = service.record(
|
||||||
|
10L, 1L, "BOSS", "STAFF_INVITATION_CREATED", "staff_invitation", "public-id",
|
||||||
|
"SUCCESS", Map.of("validDays", 7, "replaced", false)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("staff_invitation_created", saved.getAction());
|
||||||
|
assertEquals("boss", saved.getActorRole());
|
||||||
|
assertEquals(
|
||||||
|
Map.of("validDays", 7, "replaced", false),
|
||||||
|
new ObjectMapper().readValue(saved.getMetadataJson(), Map.class)
|
||||||
|
);
|
||||||
|
verify(auditLogMapper).save(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCredentialOrPiiMetadata() {
|
||||||
|
AuditLogService service = new AuditLogService(auditLogMapper, new ObjectMapper());
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.record(
|
||||||
|
10L, 1L, "boss", "test", "store", "10", "success",
|
||||||
|
Map.of("inviteToken", "secret")
|
||||||
|
));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> service.record(
|
||||||
|
10L, 1L, "boss", "test", "store", "10", "success",
|
||||||
|
Map.of("phone", "13800138000")
|
||||||
|
));
|
||||||
|
verifyNoInteractions(auditLogMapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.auth.SessionTokenService;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class MerchantOnboardingServiceTest {
|
||||||
|
|
||||||
|
@Mock private UserMapper userMapper;
|
||||||
|
@Mock private StoreMapper storeMapper;
|
||||||
|
@Mock private SessionTokenService sessionTokenService;
|
||||||
|
@Mock private BusinessEventService businessEventService;
|
||||||
|
@Mock private AuditLogService auditLogService;
|
||||||
|
|
||||||
|
private MerchantOnboardingService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new MerchantOnboardingService(
|
||||||
|
userMapper, storeMapper, sessionTokenService, businessEventService, auditLogService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifiedPhoneCreatesBossWithoutPasswordCredentialAndWritesFacts() {
|
||||||
|
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null);
|
||||||
|
when(userMapper.findByWechatOpenidAndDeletedFalse("openid-x")).thenReturn(Optional.empty());
|
||||||
|
when(userMapper.findByWechatUnionidAndDeletedFalse("unionid-x")).thenReturn(Optional.empty());
|
||||||
|
when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> {
|
||||||
|
Store store = invocation.getArgument(0);
|
||||||
|
if (store.getId() == null) store.setId(10L);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
when(userMapper.save(any(User.class))).thenAnswer(invocation -> {
|
||||||
|
User user = invocation.getArgument(0);
|
||||||
|
user.setId(1L);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
when(sessionTokenService.issue(1L, 10L, "boss")).thenReturn("session");
|
||||||
|
|
||||||
|
Map<String, Object> result = service.registerVerifiedBoss(
|
||||||
|
" 测试宠物店 ", " 店长 ", "13800138000", "openid-x", "unionid-x"
|
||||||
|
);
|
||||||
|
|
||||||
|
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||||
|
verify(userMapper).save(userCaptor.capture());
|
||||||
|
User boss = userCaptor.getValue();
|
||||||
|
assertEquals("boss", boss.getRole());
|
||||||
|
assertEquals("wx_no_password", boss.getPassword());
|
||||||
|
assertFalse(result.toString().contains("wx_no_password"));
|
||||||
|
assertEquals("session", result.get("sessionToken"));
|
||||||
|
|
||||||
|
ArgumentCaptor<Store> storeCaptor = ArgumentCaptor.forClass(Store.class);
|
||||||
|
verify(businessEventService).recordStoreRegistered(storeCaptor.capture(), eq(1L));
|
||||||
|
assertEquals("测试宠物店", storeCaptor.getValue().getName());
|
||||||
|
verify(auditLogService).record(
|
||||||
|
eq(10L), eq(1L), eq("boss"), eq("store_registered"),
|
||||||
|
eq("store"), eq("10"), eq("success"), any(Map.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void duplicatePhoneStopsBeforeCreatingStore() {
|
||||||
|
User existing = new User();
|
||||||
|
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(existing);
|
||||||
|
|
||||||
|
FlowException error = assertThrows(FlowException.class, () -> service.registerVerifiedBoss(
|
||||||
|
"测试宠物店", "店长", "13800138000", "openid-x", null
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("PHONE_ALREADY_REGISTERED", error.bizCode());
|
||||||
|
verify(storeMapper, never()).save(any(Store.class));
|
||||||
|
verify(userMapper, never()).save(any(User.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,170 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.auth.SessionTokenService;
|
||||||
|
import com.petstore.entity.StaffInvitation;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.StaffInvitationMapper;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StaffInvitationServiceTest {
|
||||||
|
|
||||||
|
@Mock private StaffInvitationMapper invitationMapper;
|
||||||
|
@Mock private StoreMapper storeMapper;
|
||||||
|
@Mock private UserMapper userMapper;
|
||||||
|
@Mock private SessionTokenService sessionTokenService;
|
||||||
|
@Mock private AuditLogService auditLogService;
|
||||||
|
|
||||||
|
private StaffInvitationService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new StaffInvitationService(
|
||||||
|
invitationMapper, storeMapper, userMapper, sessionTokenService, auditLogService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createStoresHashAndReturnsRawTokenOnlyOnce() {
|
||||||
|
Store store = store();
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store));
|
||||||
|
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null);
|
||||||
|
when(invitationMapper.findByStoreIdAndInvitedPhoneAndStatus(10L, "13800138000", "pending"))
|
||||||
|
.thenReturn(java.util.List.of());
|
||||||
|
when(invitationMapper.save(any(StaffInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
Map<String, Object> result = service.create(
|
||||||
|
10L, 1L, "boss", "小李", "13800138000", 7
|
||||||
|
);
|
||||||
|
|
||||||
|
String rawToken = result.get("inviteToken").toString();
|
||||||
|
ArgumentCaptor<StaffInvitation> captor = ArgumentCaptor.forClass(StaffInvitation.class);
|
||||||
|
verify(invitationMapper).save(captor.capture());
|
||||||
|
StaffInvitation persisted = captor.getValue();
|
||||||
|
assertEquals(43, rawToken.length());
|
||||||
|
assertEquals(64, persisted.getTokenHash().length());
|
||||||
|
assertNotEquals(rawToken, persisted.getTokenHash());
|
||||||
|
assertEquals(StaffInvitationService.hashToken(rawToken), persisted.getTokenHash());
|
||||||
|
assertEquals("138****8000", result.get("maskedPhone"));
|
||||||
|
assertFalse(result.toString().contains("13800138000"));
|
||||||
|
assertTrue(result.get("invitePath").toString().contains(rawToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptRejectsMismatchedVerifiedPhoneWithoutCreatingAccount() {
|
||||||
|
StaffInvitation invitation = pendingInvitation("token");
|
||||||
|
when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token")))
|
||||||
|
.thenReturn(Optional.of(invitation));
|
||||||
|
|
||||||
|
FlowException error = assertThrows(FlowException.class, () -> service.acceptVerified(
|
||||||
|
"token", "13900139000", null, null
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("INVITATION_PHONE_MISMATCH", error.bizCode());
|
||||||
|
verify(userMapper, never()).save(any(User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptCreatesStaffAndConsumesInvitation() {
|
||||||
|
StaffInvitation invitation = pendingInvitation("token");
|
||||||
|
Store store = store();
|
||||||
|
when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token")))
|
||||||
|
.thenReturn(Optional.of(invitation));
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store));
|
||||||
|
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null);
|
||||||
|
when(userMapper.findByWechatOpenidAndDeletedFalse("openid-x")).thenReturn(Optional.empty());
|
||||||
|
when(userMapper.save(any(User.class))).thenAnswer(invocation -> {
|
||||||
|
User user = invocation.getArgument(0);
|
||||||
|
user.setId(22L);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
when(invitationMapper.save(any(StaffInvitation.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
when(sessionTokenService.issue(22L, 10L, "staff")).thenReturn("session");
|
||||||
|
|
||||||
|
Map<String, Object> result = service.acceptVerified(
|
||||||
|
"token", "13800138000", "openid-x", null
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("accepted", invitation.getStatus());
|
||||||
|
assertEquals(22L, invitation.getAcceptedByUserId());
|
||||||
|
assertEquals("session", result.get("sessionToken"));
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> user = (Map<String, Object>) result.get("user");
|
||||||
|
assertEquals("staff", user.get("role"));
|
||||||
|
verify(auditLogService).record(
|
||||||
|
eq(10L), eq(22L), eq("staff"), eq("staff_invitation_accepted"),
|
||||||
|
eq("staff_invitation"), eq("invite-id"), eq("success"), any(Map.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptedInvitationOnlyRestoresSameActiveStaffSession() {
|
||||||
|
StaffInvitation invitation = pendingInvitation("token");
|
||||||
|
invitation.setStatus("accepted");
|
||||||
|
invitation.setAcceptedByUserId(22L);
|
||||||
|
invitation.setAcceptedAt(LocalDateTime.now());
|
||||||
|
User staff = new User();
|
||||||
|
staff.setId(22L);
|
||||||
|
staff.setPhone("13800138000");
|
||||||
|
staff.setStoreId(10L);
|
||||||
|
staff.setRole("staff");
|
||||||
|
when(invitationMapper.findByTokenHashForUpdate(StaffInvitationService.hashToken("token")))
|
||||||
|
.thenReturn(Optional.of(invitation));
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store()));
|
||||||
|
when(userMapper.findByIdAndDeletedFalse(22L)).thenReturn(Optional.of(staff));
|
||||||
|
when(sessionTokenService.issue(22L, 10L, "staff")).thenReturn("session");
|
||||||
|
|
||||||
|
Map<String, Object> result = service.acceptVerified(
|
||||||
|
"token", "13800138000", "ignored-openid", null
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(true, result.get("alreadyAccepted"));
|
||||||
|
assertEquals("session", result.get("sessionToken"));
|
||||||
|
verify(userMapper, never()).save(any(User.class));
|
||||||
|
verify(invitationMapper, never()).save(any(StaffInvitation.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Store store() {
|
||||||
|
Store store = new Store();
|
||||||
|
store.setId(10L);
|
||||||
|
store.setName("测试宠物店");
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaffInvitation pendingInvitation(String rawToken) {
|
||||||
|
StaffInvitation invitation = new StaffInvitation();
|
||||||
|
invitation.setInvitationId("invite-id");
|
||||||
|
invitation.setTokenHash(StaffInvitationService.hashToken(rawToken));
|
||||||
|
invitation.setStoreId(10L);
|
||||||
|
invitation.setInvitedName("小李");
|
||||||
|
invitation.setInvitedPhone("13800138000");
|
||||||
|
invitation.setStatus("pending");
|
||||||
|
invitation.setExpiresAt(LocalDateTime.now().plusDays(1));
|
||||||
|
invitation.setCreateTime(LocalDateTime.now());
|
||||||
|
invitation.setUpdateTime(LocalDateTime.now());
|
||||||
|
return invitation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.entity.ServiceType;
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.entity.User;
|
||||||
|
import com.petstore.mapper.ServiceTypeMapper;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import com.petstore.mapper.UserMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StoreOnboardingServiceTest {
|
||||||
|
|
||||||
|
@Mock private StoreMapper storeMapper;
|
||||||
|
@Mock private UserMapper userMapper;
|
||||||
|
@Mock private ServiceTypeMapper serviceTypeMapper;
|
||||||
|
@Mock private AuditLogService auditLogService;
|
||||||
|
|
||||||
|
private StoreOnboardingService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new StoreOnboardingService(storeMapper, userMapper, serviceTypeMapper, auditLogService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addressIsRequiredButStaffInvitationIsOptional() {
|
||||||
|
Store store = readyStore();
|
||||||
|
store.setAddress(null);
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(store));
|
||||||
|
when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType()));
|
||||||
|
when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss()));
|
||||||
|
|
||||||
|
Map<String, Object> status = service.getStatus(10L);
|
||||||
|
|
||||||
|
assertFalse((Boolean) status.get("readyToComplete"));
|
||||||
|
assertEquals(List.of("门店资料"), status.get("missingRequiredSteps"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bossCanCompleteReadySoloStoreOnce() {
|
||||||
|
Store store = readyStore();
|
||||||
|
when(storeMapper.findByIdAndDeletedFalseForUpdate(10L)).thenReturn(Optional.of(store));
|
||||||
|
when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType()));
|
||||||
|
when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss()));
|
||||||
|
when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
Map<String, Object> status = service.complete(10L, 1L, "boss");
|
||||||
|
|
||||||
|
assertEquals("completed", status.get("status"));
|
||||||
|
assertTrue((Boolean) status.get("readyToComplete"));
|
||||||
|
assertEquals(false, status.get("alreadyCompleted"));
|
||||||
|
verify(auditLogService).record(
|
||||||
|
eq(10L), eq(1L), eq("boss"), eq("store_onboarding_completed"),
|
||||||
|
eq("store"), eq("10"), eq("success"), any(Map.class)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cannotCompleteWhenRequiredStepMissing() {
|
||||||
|
Store store = readyStore();
|
||||||
|
store.setAddress("");
|
||||||
|
when(storeMapper.findByIdAndDeletedFalseForUpdate(10L)).thenReturn(Optional.of(store));
|
||||||
|
when(serviceTypeMapper.findByStoreIdOrStoreIdIsNull(10L)).thenReturn(List.of(serviceType()));
|
||||||
|
when(userMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(boss()));
|
||||||
|
|
||||||
|
FlowException error = assertThrows(FlowException.class, () -> service.complete(10L, 1L, "boss"));
|
||||||
|
|
||||||
|
assertEquals("ONBOARDING_NOT_READY", error.bizCode());
|
||||||
|
verify(storeMapper, never()).save(any(Store.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Store readyStore() {
|
||||||
|
Store store = new Store();
|
||||||
|
store.setId(10L);
|
||||||
|
store.setName("测试宠物店");
|
||||||
|
store.setPhone("13800138000");
|
||||||
|
store.setAddress("测试路 1 号");
|
||||||
|
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||||
|
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||||
|
store.setBookingCapacity(1);
|
||||||
|
store.setOnboardingStatus("in_progress");
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceType serviceType() {
|
||||||
|
ServiceType serviceType = new ServiceType();
|
||||||
|
serviceType.setName("洗护");
|
||||||
|
return serviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User boss() {
|
||||||
|
User user = new User();
|
||||||
|
user.setRole("boss");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/test/java/com/petstore/service/StoreServiceTest.java
Normal file
78
src/test/java/com/petstore/service/StoreServiceTest.java
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.entity.Store;
|
||||||
|
import com.petstore.mapper.StoreMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class StoreServiceTest {
|
||||||
|
|
||||||
|
@Mock private StoreMapper storeMapper;
|
||||||
|
|
||||||
|
private StoreService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new StoreService(storeMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedStoreCannotClearRequiredProfile() {
|
||||||
|
Store existing = completedStore();
|
||||||
|
Store incoming = new Store();
|
||||||
|
incoming.setId(10L);
|
||||||
|
incoming.setAddress(" ");
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
IllegalArgumentException error = assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> service.update(incoming)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("已开通门店的名称、电话和地址不能为空", error.getMessage());
|
||||||
|
verify(storeMapper, never()).save(any(Store.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedStoreCanUpdateRequiredProfileWithNonBlankValue() {
|
||||||
|
Store existing = completedStore();
|
||||||
|
Store incoming = new Store();
|
||||||
|
incoming.setId(10L);
|
||||||
|
incoming.setAddress("新地址 2 号");
|
||||||
|
when(storeMapper.findByIdAndDeletedFalse(10L)).thenReturn(Optional.of(existing));
|
||||||
|
when(storeMapper.save(any(Store.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
Store updated = service.update(incoming);
|
||||||
|
|
||||||
|
assertEquals("新地址 2 号", updated.getAddress());
|
||||||
|
verify(storeMapper).save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Store completedStore() {
|
||||||
|
Store store = new Store();
|
||||||
|
store.setId(10L);
|
||||||
|
store.setName("测试宠物店");
|
||||||
|
store.setPhone("13800138000");
|
||||||
|
store.setAddress("测试路 1 号");
|
||||||
|
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||||
|
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||||
|
store.setBookingCapacity(1);
|
||||||
|
store.setOnboardingStatus("completed");
|
||||||
|
store.setDeleted(false);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user