瀏覽代碼

feat: 注册邀约制 — 邀请码生成+校验+管理页, 修复uuid/密码DTO

- 后端: 新增invite模块(InvitationCodeEntity/Mapper/Service/Controller/VO)
- 注册: 必须先校验邀请码有效性再创建用户, 原子claimCode防并发
- 前端: 注册页启用邀请码输入框, 新增邀请码管理页(generate/list/delete)
- 修复: UserService三处创建用户漏设uuid NOT NULL, ChangePasswordDTO对齐前端字段
- Security: /register + /forget/password加入白名单
- SQL: 004_invitation_code(DDL) + 005_invitation_menu(菜单)
alphah 1 天之前
父節點
當前提交
9e4d5c3c49

+ 43 - 0
frontend/src/api/module_system/invitation.ts

@@ -0,0 +1,43 @@
+import request from "@/utils/request";
+
+const API_PATH = "/system/invite";
+
+const InvitationAPI = {
+  generate(body: { count: number; description?: string }) {
+    return request<ApiResponse<string[]>>({
+      url: `${API_PATH}/generate`,
+      method: "post",
+      data: body,
+    });
+  },
+
+  list(query: InvitationPageQuery) {
+    return request<ApiResponse<PageResult<InvitationTable[]>>>({
+      url: `${API_PATH}/list`,
+      method: "get",
+      params: query,
+    });
+  },
+
+  delete(body: number[]) {
+    return request<ApiResponse>({
+      url: `${API_PATH}/delete`,
+      method: "delete",
+      data: body,
+    });
+  },
+};
+
+export default InvitationAPI;
+
+export interface InvitationPageQuery extends PageQuery {
+  code?: string;
+  status?: string;
+}
+
+export interface InvitationTable extends BaseType {
+  code?: string;
+  created_by?: string;
+  used_by?: string;
+  used_time?: string;
+}

+ 1 - 1
frontend/src/api/module_system/user.ts

@@ -150,7 +150,7 @@ export interface ForgetPasswordForm {
 export interface RegisterForm {
   sms_code: string;
   mobile: string;
-  // invite_code: string;
+  invite_code: string;
   username: string;
   password: string;
   confirmPassword: string;

+ 9 - 9
frontend/src/views/module_system/auth/components/Register.vue

@@ -3,13 +3,13 @@
     <h3 text-center m-0 mb-20px>{{ t("login.reg") }}</h3>
     <el-form ref="formRef" :model="model" :rules="rules" size="large" label-suffix=":">
       <!-- 邀请码 -->
-      <!-- <el-form-item prop="invite_code">
+      <el-form-item prop="invite_code">
         <el-input v-model.trim="model.invite_code" placeholder="请输入邀请码" clearable>
           <template #prefix>
             <el-icon><SetUp /></el-icon>
           </template>
         </el-input>
-      </el-form-item> -->
+      </el-form-item>
 
       <el-form-item prop="mobile">
         <el-input v-model.trim="model.mobile" placeholder="请输入手机号" clearable>
@@ -139,13 +139,13 @@ const model = ref<RegisterForm>({
 
 const rules = computed(() => {
   return {
-    // invite_code: [
-    //   {
-    //     required: true,
-    //     trigger: "blur",
-    //     message: "请输入邀请码",
-    //   },
-    // ],
+    invite_code: [
+      {
+        required: true,
+        trigger: "blur",
+        message: "请输入邀请码",
+      },
+    ],
     // 验证国内手机号格式
     mobile: [
       {

+ 321 - 0
frontend/src/views/module_system/invitation/index.vue

@@ -0,0 +1,321 @@
+<!-- 邀请码管理 -->
+<template>
+  <div class="app-container">
+    <PageSearch
+      ref="searchRef"
+      :search-config="searchConfig"
+      @query-click="handleQueryClick"
+      @reset-click="handleResetClick"
+    />
+
+    <PageContent ref="contentRef" :content-config="contentConfig">
+      <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
+        <CrudToolbarLeft
+          :remove-ids="removeIds"
+          :perm-create="['module_system:invitation:create']"
+          :perm-delete="['module_system:invitation:delete']"
+          create-label="生成邀请码"
+          @add="handleOpenGenerateDialog"
+          @delete="onToolbar('delete')"
+        />
+        <div class="data-table__toolbar--right">
+          <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
+        </div>
+      </template>
+
+      <template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
+        <div class="data-table__content">
+          <el-table
+            :ref="tableRef as any"
+            v-loading="loading"
+            row-key="id"
+            :data="data"
+            height="100%"
+            border
+            stripe
+            @selection-change="onSelectionChange"
+          >
+            <template #empty>
+              <el-empty :image-size="70" description="暂无数据" />
+            </template>
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'selection')?.show"
+              type="selection"
+              min-width="55"
+              align="center"
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'index')?.show"
+              fixed
+              label="序号"
+              min-width="60"
+            >
+              <template #default="scope">
+                {{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
+              </template>
+            </el-table-column>
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'code')?.show"
+              label="邀请码"
+              prop="code"
+              min-width="160"
+              show-overflow-tooltip
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'status')?.show"
+              label="状态"
+              prop="status"
+              min-width="100"
+            >
+              <template #default="scope">
+                <el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
+                  {{ scope.row.status === "0" ? "未使用" : "已使用" }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'description')?.show"
+              label="备注"
+              prop="description"
+              min-width="160"
+              show-overflow-tooltip
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'created_by')?.show"
+              label="创建者"
+              prop="created_by"
+              min-width="120"
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
+              label="创建时间"
+              prop="created_time"
+              min-width="180"
+              sortable
+              show-overflow-tooltip
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'used_by')?.show"
+              label="使用者"
+              prop="used_by"
+              min-width="120"
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'used_time')?.show"
+              label="使用时间"
+              prop="used_time"
+              min-width="180"
+              sortable
+              show-overflow-tooltip
+            />
+            <el-table-column
+              v-if="contentCols.find((col) => col.prop === 'operation')?.show"
+              fixed="right"
+              label="操作"
+              align="center"
+              min-width="100"
+            >
+              <template #default="scope">
+                <el-button
+                  v-hasPerm="['module_system:invitation:delete']"
+                  type="danger"
+                  size="small"
+                  link
+                  icon="delete"
+                  :disabled="scope.row.status === '1'"
+                  @click="handleRowDelete(scope.row.id)"
+                >
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+      </template>
+    </PageContent>
+
+    <EnhancedDialog
+      v-model="generateDialogVisible"
+      title="生成邀请码"
+      @close="handleCloseGenerateDialog"
+    >
+      <el-form
+        ref="generateFormRef"
+        :model="generateForm"
+        :rules="generateRules"
+        label-suffix=":"
+        label-width="auto"
+        label-position="right"
+      >
+        <el-form-item label="数量" prop="count">
+          <el-input-number v-model="generateForm.count" :min="1" :max="100" />
+        </el-form-item>
+        <el-form-item label="备注" prop="description">
+          <el-input
+            v-model="generateForm.description"
+            :maxlength="255"
+            show-word-limit
+            type="textarea"
+            placeholder="选填"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button @click="handleCloseGenerateDialog">取消</el-button>
+          <el-button type="primary" :loading="generateLoading" @click="handleGenerate">
+            确定
+          </el-button>
+        </div>
+      </template>
+    </EnhancedDialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from "vue";
+import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
+import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
+import PageSearch from "@/components/CURD/PageSearch.vue";
+import PageContent from "@/components/CURD/PageContent.vue";
+import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
+import type { IContentConfig, ISearchConfig } from "@/components/CURD/types";
+import { useCrudList } from "@/components/CURD/useCrudList";
+import InvitationAPI, { InvitationTable, InvitationPageQuery } from "@/api/module_system/invitation";
+
+defineOptions({
+  name: "Invitation",
+  inheritAttrs: false,
+});
+
+const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
+
+const contentCols = reactive<
+  Array<{ prop?: string; label?: string; show?: boolean }>
+>([
+  { prop: "selection", label: "选择框", show: true },
+  { prop: "index", label: "序号", show: true },
+  { prop: "code", label: "邀请码", show: true },
+  { prop: "status", label: "状态", show: true },
+  { prop: "description", label: "备注", show: true },
+  { prop: "created_by", label: "创建者", show: true },
+  { prop: "created_time", label: "创建时间", show: true },
+  { prop: "used_by", label: "使用者", show: true },
+  { prop: "used_time", label: "使用时间", show: true },
+  { prop: "operation", label: "操作", show: true },
+]);
+
+const searchConfig = reactive<ISearchConfig>({
+  permPrefix: "module_system:invitation",
+  colon: true,
+  form: { labelWidth: "auto" },
+  formItems: [
+    {
+      prop: "code",
+      label: "邀请码",
+      type: "input",
+      attrs: { placeholder: "请输入邀请码", clearable: true },
+    },
+    {
+      prop: "status",
+      label: "状态",
+      type: "select",
+      options: [
+        { label: "未使用", value: "0" },
+        { label: "已使用", value: "1" },
+      ],
+      attrs: { placeholder: "请选择状态", clearable: true, style: { width: "167.5px" } },
+    },
+  ],
+});
+
+const contentConfig = reactive<IContentConfig<InvitationPageQuery>>({
+  permPrefix: "module_system:invitation",
+  pk: "id",
+  cols: contentCols as IContentConfig["cols"],
+  hideColumnFilter: false,
+  toolbar: [],
+  defaultToolbar: ["refresh", "filter"],
+  pagination: {
+    pageSize: 10,
+    pageSizes: [10, 20, 30, 50],
+  },
+  request: { page_no: "page_no", page_size: "page_size" },
+  indexAction: async (params) => {
+    const res = await InvitationAPI.list(params as InvitationPageQuery);
+    return {
+      total: res.data.data.total,
+      list: res.data.data.items,
+    };
+  },
+  deleteAction: async (ids) => {
+    await InvitationAPI.delete(
+      ids
+        .split(",")
+        .map((s) => Number(s.trim()))
+        .filter((n) => !Number.isNaN(n))
+    );
+  },
+  deleteConfirm: {
+    title: "警告",
+    message: "确认删除该项数据?",
+    type: "warning",
+  },
+});
+
+function handleRowDelete(id: number) {
+  contentRef.value?.handleDelete(id);
+}
+
+// ---- 生成弹窗 ----
+
+const generateDialogVisible = ref(false);
+const generateFormRef = ref();
+const generateLoading = ref(false);
+
+const generateForm = reactive({
+  count: 1 as number,
+  description: "" as string,
+});
+
+const generateRules = reactive({
+  count: [
+    { required: true, message: "请输入生成数量", trigger: "blur" },
+  ],
+});
+
+function handleOpenGenerateDialog() {
+  generateDialogVisible.value = true;
+}
+
+function handleCloseGenerateDialog() {
+  generateDialogVisible.value = false;
+  generateForm.count = 1;
+  generateForm.description = "";
+  generateFormRef.value?.resetFields();
+}
+
+async function handleGenerate() {
+  if (!generateFormRef.value) return;
+  generateFormRef.value.validate(async (valid: any) => {
+    if (!valid) return;
+    generateLoading.value = true;
+    try {
+      await InvitationAPI.generate({
+        count: generateForm.count,
+        description: generateForm.description || undefined,
+      });
+      generateDialogVisible.value = false;
+      generateForm.count = 1;
+      generateForm.description = "";
+      refreshList();
+    } catch (error: any) {
+      console.error(error);
+    } finally {
+      generateLoading.value = false;
+    }
+  });
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 15 - 0
java/sql/004_invitation_code.sql

@@ -0,0 +1,15 @@
+-- 邀请码表 — 注册邀约制
+CREATE TABLE sys_invitation_code (
+    id           BIGSERIAL PRIMARY KEY,
+    uuid         VARCHAR(64),
+    status       VARCHAR(1)  DEFAULT '0',      -- 0=未使用, 1=已使用
+    code         VARCHAR(64)  NOT NULL,
+    used_by      BIGINT,                        -- 使用者 user_id
+    used_time    TIMESTAMPTZ,                   -- 使用时间
+    created_by   BIGINT,                        -- 生成者 user_id
+    description  VARCHAR(255),
+    created_time TIMESTAMPTZ DEFAULT now(),
+    updated_time TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE UNIQUE INDEX idx_invitation_code ON sys_invitation_code (code);

+ 20 - 0
java/sql/005_invitation_menu.sql

@@ -0,0 +1,20 @@
+-- 邀请码管理 — 菜单 + 权限
+WITH system_menu AS (
+    SELECT id FROM sys_menu WHERE name = '系统管理' AND type = 1 AND status = '0' LIMIT 1
+),
+inserted AS (
+    INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                          redirect, hidden, keep_alive, always_show, affix, title, params,
+                          uuid, parent_id, status, created_time, updated_time)
+    SELECT '邀请码管理', 2, 98,
+           'module_system:invitation:list',
+           'key', 'Invitation', '/invitation', 'views/module_system/invitation/index.vue',
+           null, false, true, false, false, '邀请码管理', null,
+           gen_random_uuid(),
+           system_menu.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+    FROM system_menu
+    WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE name = '邀请码管理' AND status = '0')
+    RETURNING id
+)
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT 1, id FROM inserted;

+ 2 - 0
java/src/main/java/com/payment/platform/core/security/SecurityConfig.java

@@ -46,6 +46,8 @@ public class SecurityConfig {
             "/system/auth/auto-login",
             "/system/auth/auto-login/users",
             "/system/auth/auto-login/token",
+            "/system/user/register",
+            "/system/user/forget/password",
             "/system/user/current/info",
             "/system/param/info",
             "/system/notice/available",

+ 2 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -37,7 +37,8 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             "sys_user_roles",     // 用户角色关联表(无 tenant_id 列)
             "sys_user_positions", // 用户岗位关联表(无 tenant_id 列)
             "sys_user_social",    // 用户第三方登录(无 tenant_id 列)
-            "pay_service_provider" // 服务商配置(无 tenant_id 列)
+            "pay_service_provider", // 服务商配置(无 tenant_id 列)
+            "sys_invitation_code" // 邀请码表(无 tenant_id 列,系统级功能)
     );
 
     public TenantInnerInterceptor() {

+ 45 - 0
java/src/main/java/com/payment/platform/module/system/invite/controller/InvitationCodeController.java

@@ -0,0 +1,45 @@
+package com.payment.platform.module.system.invite.controller;
+
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.system.invite.service.InvitationCodeService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "邀请码管理")
+@RestController
+@RequestMapping("/system/invite")
+@RequiredArgsConstructor
+public class InvitationCodeController {
+
+    private final InvitationCodeService invitationCodeService;
+
+    @Operation(summary = "生成邀请码")
+    @PostMapping("/generate")
+    public Result<List<String>> generate(@RequestBody Map<String, Object> body) {
+        int count = body.containsKey("count") ? Integer.parseInt(body.get("count").toString()) : 1;
+        String description = body.containsKey("description") ? body.get("description").toString() : null;
+        return Result.ok(invitationCodeService.generate(count, description));
+    }
+
+    @Operation(summary = "邀请码列表")
+    @GetMapping("/list")
+    public Result<?> list(
+            @RequestParam(name = "page_no", defaultValue = "1") int pageNo,
+            @RequestParam(name = "page_size", defaultValue = "10") int pageSize,
+            @RequestParam(required = false) String code,
+            @RequestParam(required = false) String status) {
+        return Result.ok(invitationCodeService.list(pageNo, pageSize, code, status));
+    }
+
+    @Operation(summary = "删除邀请码")
+    @DeleteMapping("/delete")
+    public Result<Void> delete(@RequestBody List<Long> ids) {
+        invitationCodeService.delete(ids);
+        return Result.ok();
+    }
+}

+ 18 - 0
java/src/main/java/com/payment/platform/module/system/invite/dto/InvitationCodeVO.java

@@ -0,0 +1,18 @@
+package com.payment.platform.module.system.invite.dto;
+
+import lombok.Data;
+
+import java.time.OffsetDateTime;
+
+@Data
+public class InvitationCodeVO {
+
+    private Long id;
+    private String code;
+    private String status;
+    private String description;
+    private String createdBy;
+    private OffsetDateTime createdTime;
+    private String usedBy;
+    private OffsetDateTime usedTime;
+}

+ 25 - 0
java/src/main/java/com/payment/platform/module/system/invite/entity/InvitationCodeEntity.java

@@ -0,0 +1,25 @@
+package com.payment.platform.module.system.invite.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.payment.platform.common.base.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.OffsetDateTime;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_invitation_code")
+public class InvitationCodeEntity extends BaseEntity {
+
+    private String code;
+
+    @TableField("used_by")
+    private Long usedBy;
+
+    private OffsetDateTime usedTime;
+
+    @TableField("created_by")
+    private Long createdBy;
+}

+ 21 - 0
java/src/main/java/com/payment/platform/module/system/invite/mapper/InvitationCodeMapper.java

@@ -0,0 +1,21 @@
+package com.payment.platform.module.system.invite.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.payment.platform.module.system.invite.entity.InvitationCodeEntity;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.time.OffsetDateTime;
+
+@Mapper
+public interface InvitationCodeMapper extends BaseMapper<InvitationCodeEntity> {
+
+    @Select("SELECT * FROM sys_invitation_code WHERE code = #{code}")
+    InvitationCodeEntity selectByCode(@Param("code") String code);
+
+    /** 原子认领邀请码:status='0'→'1',防止并发重复使用 */
+    @Update("UPDATE sys_invitation_code SET status = '1', used_by = #{userId}, used_time = #{usedTime} WHERE code = #{code} AND status = '0'")
+    int claimCode(@Param("code") String code, @Param("userId") Long userId, @Param("usedTime") OffsetDateTime usedTime);
+}

+ 142 - 0
java/src/main/java/com/payment/platform/module/system/invite/service/InvitationCodeService.java

@@ -0,0 +1,142 @@
+package com.payment.platform.module.system.invite.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.system.invite.dto.InvitationCodeVO;
+import com.payment.platform.module.system.invite.entity.InvitationCodeEntity;
+import com.payment.platform.module.system.invite.mapper.InvitationCodeMapper;
+import com.payment.platform.module.system.user.entity.UserEntity;
+import com.payment.platform.module.system.user.mapper.UserMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class InvitationCodeService {
+
+    private final InvitationCodeMapper invitationCodeMapper;
+    private final UserMapper userMapper;
+
+    private LoginUser currentUser() {
+        Authentication a = SecurityContextHolder.getContext().getAuthentication();
+        return (a != null && a.getPrincipal() instanceof LoginUser u) ? u : null;
+    }
+
+    private void requireSuperuser() {
+        LoginUser u = currentUser();
+        if (u == null || u.getIsSuperuser() == null || !u.getIsSuperuser()) {
+            throw new BusinessException(403, "仅超级管理员可操作");
+        }
+    }
+
+    private String generateCode() {
+        return UUID.randomUUID().toString().replace("-", "").substring(0, 12);
+    }
+
+    @Transactional
+    public List<String> generate(int count, String description) {
+        requireSuperuser();
+        LoginUser u = currentUser();
+        List<String> codes = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            String code;
+            int retries = 0;
+            do {
+                code = generateCode();
+                retries++;
+                if (retries > 10) throw new BusinessException(500, "邀请码生成失败,请重试");
+            } while (invitationCodeMapper.selectByCode(code) != null);
+
+            InvitationCodeEntity entity = new InvitationCodeEntity();
+            entity.setCode(code);
+            entity.setStatus("0");
+            entity.setCreatedBy(u.getUserId());
+            entity.setDescription(description);
+            invitationCodeMapper.insert(entity);
+            codes.add(code);
+        }
+        return codes;
+    }
+
+    public PageResult<InvitationCodeVO> list(int pageNo, int pageSize, String code, String status) {
+        requireSuperuser();
+        LambdaQueryWrapper<InvitationCodeEntity> w = new LambdaQueryWrapper<>();
+        if (StrUtil.isNotBlank(code)) w.like(InvitationCodeEntity::getCode, code);
+        if (StrUtil.isNotBlank(status)) w.eq(InvitationCodeEntity::getStatus, status);
+        w.orderByDesc(InvitationCodeEntity::getId);
+        Page<InvitationCodeEntity> page = new Page<>(pageNo, pageSize);
+        Page<InvitationCodeEntity> result = invitationCodeMapper.selectPage(page, w);
+
+        List<InvitationCodeVO> vos = result.getRecords().stream().map(e -> {
+            InvitationCodeVO vo = new InvitationCodeVO();
+            vo.setId(e.getId());
+            vo.setCode(e.getCode());
+            vo.setStatus(e.getStatus());
+            vo.setDescription(e.getDescription());
+            vo.setCreatedTime(e.getCreatedTime());
+            vo.setUsedTime(e.getUsedTime());
+            if (e.getCreatedBy() != null) {
+                UserEntity u = userMapper.selectById(e.getCreatedBy());
+                vo.setCreatedBy(u != null ? u.getUsername() : null);
+            }
+            if (e.getUsedBy() != null) {
+                UserEntity u = userMapper.selectById(e.getUsedBy());
+                vo.setUsedBy(u != null ? u.getUsername() : null);
+            }
+            return vo;
+        }).collect(Collectors.toList());
+
+        return PageResult.of(pageNo, pageSize, result.getTotal(), vos);
+    }
+
+    @Transactional
+    public void delete(List<Long> ids) {
+        requireSuperuser();
+        if (ids.isEmpty()) throw new BusinessException(400, "删除对象不能为空");
+        for (Long id : ids) {
+            InvitationCodeEntity entity = invitationCodeMapper.selectById(id);
+            if (entity == null) throw new BusinessException(404, "邀请码不存在");
+            if ("1".equals(entity.getStatus()))
+                throw new BusinessException(400, "邀请码 " + entity.getCode() + " 已使用,不能删除");
+        }
+        invitationCodeMapper.deleteBatchIds(ids);
+    }
+
+    /**
+     * 校验邀请码是否存在且未使用(预检,调用方应在用户创建前调用)
+     */
+    public void validate(String code) {
+        InvitationCodeEntity entity = invitationCodeMapper.selectByCode(code);
+        if (entity == null) throw new BusinessException(400, "邀请码无效");
+        if ("1".equals(entity.getStatus())) throw new BusinessException(400, "邀请码已被使用");
+    }
+
+    /**
+     * 原子认领邀请码(防并发),返回是否成功
+     */
+    public boolean claimCode(String code, Long userId) {
+        return invitationCodeMapper.claimCode(code, userId, OffsetDateTime.now()) > 0;
+    }
+
+    @Transactional
+    public void validateAndUse(String code, Long userId) {
+        InvitationCodeEntity entity = invitationCodeMapper.selectByCode(code);
+        if (entity == null) throw new BusinessException(400, "邀请码无效");
+
+        int rows = invitationCodeMapper.claimCode(code, userId, OffsetDateTime.now());
+        if (rows == 0) throw new BusinessException(400, "邀请码已被使用");
+    }
+}

+ 5 - 0
java/src/main/java/com/payment/platform/module/system/user/dto/ChangePasswordDTO.java

@@ -1,20 +1,25 @@
 package com.payment.platform.module.system.user.dto;
 
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
 @Data
+@JsonIgnoreProperties(ignoreUnknown = true)
 public class ChangePasswordDTO {
 
     @NotBlank(message = "原密码不能为空")
     @Size(max = 128, message = "密码最长128字符")
     @Schema(description = "原密码")
+    @JsonProperty("old_password")
     private String oldPwd;
 
     @NotBlank(message = "新密码不能为空")
     @Size(min = 6, max = 128, message = "密码长度6-128字符")
     @Schema(description = "新密码")
+    @JsonProperty("new_password")
     private String newPwd;
 }

+ 15 - 1
java/src/main/java/com/payment/platform/module/system/user/service/UserService.java

@@ -42,6 +42,7 @@ public class UserService {
     private final DeptMapper deptMapper;
     private final PositionMapper positionMapper;
     private final PasswordEncoder passwordEncoder;
+    private final com.payment.platform.module.system.invite.service.InvitationCodeService invitationCodeService;
 
     // ==================== 当前用户 ====================
 
@@ -105,6 +106,7 @@ public class UserService {
                 throw new BusinessException(400, "部门不存在");
         }
         UserEntity entity = new UserEntity();
+        entity.setUuid(UUID.randomUUID().toString());
         BeanUtil.copyProperties(data, entity);
         if (StrUtil.isNotBlank(data.getPassword())) {
             entity.setPassword(passwordEncoder.encode(data.getPassword()));
@@ -257,11 +259,17 @@ public class UserService {
         String password = getStr(body, "password");
         String mobile = getStr(body, "mobile");
         String name = getStr(body, "name");
+        String invitationCode = getStr(body, "invite_code");
 
         if (StrUtil.isBlank(username))
             throw new BusinessException(400, "用户名不能为空");
         if (StrUtil.isBlank(password))
             throw new BusinessException(400, "密码不能为空");
+        if (StrUtil.isBlank(invitationCode))
+            throw new BusinessException(400, "邀请码不能为空");
+
+        // 先校验邀请码有效性(创建用户之前)
+        invitationCodeService.validate(invitationCode);
 
         // 检查用户名是否已存在(按 username 或 mobile)
         UserEntity existByUsername = userMapper.selectOne(
@@ -277,6 +285,7 @@ public class UserService {
         }
 
         UserEntity entity = new UserEntity();
+        entity.setUuid(UUID.randomUUID().toString());
         entity.setUsername(username);
         entity.setPassword(passwordEncoder.encode(password));
         entity.setMobile(mobile);
@@ -286,7 +295,11 @@ public class UserService {
         userMapper.insert(entity);
 
         // 分配默认角色 (role_id=1)
-        userMapper.insertUserRole(entity.getId(), 1L);
+        userMapper.insertUserRole(entity.getId(), 2L);
+
+        // 原子认领邀请码
+        if (!invitationCodeService.claimCode(invitationCode, entity.getId()))
+            throw new BusinessException(400, "邀请码已被使用");
 
         return toVO(userMapper.selectById(entity.getId()));
     }
@@ -488,6 +501,7 @@ public class UserService {
                 if (StrUtil.isBlank(username)) continue;
                 String name = String.valueOf(row.getOrDefault("name", ""));
                 UserEntity entity = new UserEntity();
+                entity.setUuid(UUID.randomUUID().toString());
                 entity.setUsername(username.trim());
                 entity.setName(StrUtil.isNotBlank(name) ? name.trim() : username.trim());
                 entity.setEmail(String.valueOf(row.getOrDefault("email", "")).trim());