shinnosuke
2026-08-03 98d1455efaa4cd3d5d3213e047ccc0ac187ba05c
feat(admin): 添加政策文章管理和商户功能增强
1个文件已删除
11个文件已添加
37个文件已修改
1673 ■■■■■ 已修改文件
rencaika/pages/news/news.uvue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/package-lock.json 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/package.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/api/admin.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/layouts/AdminLayout.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/router/index.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/style.css 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/types/admin.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/BusinessManagementView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantCreateView.vue 303 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/PaymentReconciliationView.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/RoleFormView.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/StaffManagementView.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentArticleManagementView.vue 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentCreateView.vue 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentDetailView.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentListView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyEditView.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/TalentPolicyPublishView.vue 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/docs/huifu-payment-integration.md 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java 121 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminRoleRequest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminStaffRequest.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/PolicyArticleRequest.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductCreateRequest.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductUpdateRequest.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/HuifuMerchantWithdrawalService.java 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentCallbackController.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalCommand.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentCreateRequest.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentLevelUpdateRequest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/application.yml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V82__admin_role_permissions.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V83__admin_operator_profile_fields.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V84__huifu_withdrawal_binding.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V85__merchant_rights_configuration.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V86__rename_standard_talent_card_to_elite.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika/pages/news/news.uvue
@@ -41,7 +41,8 @@
    const navHeight = ref(32)
    const navWidth = ref(88)
    const navRight = ref(10)
    const pageSize = 4
    // A full first page avoids a non-scrollable four-item list on common phone viewports.
    const pageSize = 10
    const visibleNews = ref<any[]>([])
    const loadedCount = ref(0)
    const currentPage = ref(0)
talent-card-admin/package-lock.json
@@ -9,8 +9,10 @@
      "version": "0.0.0",
      "dependencies": {
        "@element-plus/icons-vue": "^2.3.2",
        "@types/leaflet": "^1.9.22",
        "axios": "^1.18.1",
        "element-plus": "^2.14.3",
        "leaflet": "^1.9.4",
        "pinia": "^4.0.2",
        "vue": "^3.5.39",
        "vue-router": "^4.6.4",
@@ -485,6 +487,21 @@
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@types/geojson": {
      "version": "7946.0.16",
      "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
      "license": "MIT"
    },
    "node_modules/@types/leaflet": {
      "version": "1.9.22",
      "resolved": "https://registry.npmmirror.com/@types/leaflet/-/leaflet-1.9.22.tgz",
      "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
      "license": "MIT",
      "dependencies": {
        "@types/geojson": "*"
      }
    },
    "node_modules/@types/lodash": {
@@ -1232,6 +1249,12 @@
        "node": ">= 6"
      }
    },
    "node_modules/leaflet": {
      "version": "1.9.4",
      "resolved": "https://registry.npmmirror.com/leaflet/-/leaflet-1.9.4.tgz",
      "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
      "license": "BSD-2-Clause"
    },
    "node_modules/lightningcss": {
      "version": "1.32.0",
      "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
talent-card-admin/package.json
@@ -10,8 +10,10 @@
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
    "@types/leaflet": "^1.9.22",
    "axios": "^1.18.1",
    "element-plus": "^2.14.3",
    "leaflet": "^1.9.4",
    "pinia": "^4.0.2",
    "vue": "^3.5.39",
    "vue-router": "^4.6.4",
talent-card-admin/src/api/admin.ts
@@ -39,6 +39,9 @@
  NewsArticle,
  NewsArticlePayload,
  NewsArticleQuery,
  PolicyArticle,
  PolicyArticlePayload,
  PolicyArticleQuery,
} from '../types/admin'
export interface ImageUploadResult {
@@ -76,11 +79,10 @@
export function getAdminStaff() { return http.get<AdminStaff[], AdminStaff[]>('/api/admin/management/staff') }
export function createAdminStaff(payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password: string }) { return http.post<AdminStaff, AdminStaff>('/api/admin/management/staff', payload) }
export function updateAdminStaff(id: number | string, payload: Omit<AdminStaff, 'id' | 'createdAt'> & { password?: string }) { return http.put<AdminStaff, AdminStaff>(`/api/admin/management/staff/${id}`, payload) }
export function resetAdminStaffPassword(id: number | string, password: string) { return http.patch<any, any>(`/api/admin/management/staff/${id}/password`, { password }) }
export function deleteAdminStaff(id: number | string) { return http.delete<any, any>(`/api/admin/management/staff/${id}`) }
export function getMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals') { return http.get<PageResult<any>, PageResult<any>>(`/api/admin/management/merchant-settlement/${kind}`) }
export function reviewMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals', id: number | string, status: string, reason = '') { return http.patch<any, any>(`/api/admin/management/merchant-settlement/${kind}/${id}`, { status, reason }) }
export function getPaymentReconciliationIssues(status = 'open') { return http.get<PageResult<any>, PageResult<any>>('/api/admin/management/payment-reconciliation/issues', { params: { status } }) }
export function closePaymentReconciliationIssue(id: number | string, status: 'resolved' | 'ignored') { return http.patch<any, any>(`/api/admin/management/payment-reconciliation/issues/${id}`, { status }) }
export function runPaymentReconciliation(pendingAfterMinutes = 5) { return http.post<any, any>('/api/admin/management/payment-reconciliation/run', undefined, { params: { pendingAfterMinutes } }) }
export function reviewMerchantSettlement(kind: 'onboardings' | 'bank-cards' | 'withdrawals', id: number | string, status: string, reason = '', providerValue = '') { const body: Record<string, string> = { status, reason }; if (kind === 'onboardings') body.huifuId = providerValue; if (kind === 'bank-cards') body.tokenNo = providerValue; return http.patch<any, any>(`/api/admin/management/merchant-settlement/${kind}/${id}`, body) }
export function getAdminNews(params: NewsArticleQuery = {}) {
  return http.get<PageResult<NewsArticle>, PageResult<NewsArticle>>('/api/admin/business/news', { params })
@@ -101,6 +103,14 @@
export function updateAdminNewsStatus(id: number | string, status: number) {
  return http.patch<NewsArticle, NewsArticle>(`/api/admin/business/news/${id}/status`, { status })
}
export function getAdminPolicies(params: PolicyArticleQuery = {}) {
  return http.get<PageResult<PolicyArticle>, PageResult<PolicyArticle>>('/api/admin/business/policies', { params })
}
export function getAdminPolicyDetail(id: number | string) { return http.get<PolicyArticle, PolicyArticle>(`/api/admin/business/policies/${id}`) }
export function createAdminPolicy(payload: PolicyArticlePayload) { return http.post<PolicyArticle, PolicyArticle>('/api/admin/business/policies', payload) }
export function updateAdminPolicy(id: number | string, payload: PolicyArticlePayload) { return http.put<PolicyArticle, PolicyArticle>(`/api/admin/business/policies/${id}`, payload) }
export function updateAdminPolicyStatus(id: number | string, status: number) { return http.patch<PolicyArticle, PolicyArticle>(`/api/admin/business/policies/${id}/status`, { status }) }
export function getTalents(params: TalentQuery = {}) {
  return http.get<PageResult<Talent>, PageResult<Talent>>('/api/talents', { params })
@@ -165,6 +175,10 @@
  return http.get<Merchant, Merchant>(`/api/admin/business/merchants/${id}`)
}
export function checkMerchantLoginAccount(account: string) {
  return http.get<{ available: boolean }, { available: boolean }>('/api/admin/business/merchants/login-account-available', { params: { account } })
}
export function createMerchant(payload: MerchantPayload) {
  return http.post<Merchant, Merchant>('/api/admin/business/merchants', payload)
}
talent-card-admin/src/layouts/AdminLayout.vue
@@ -82,7 +82,6 @@
]
const orderMenus: MenuItem[] = [
  { path: '/payment-reconciliation', label: 'Payment reconciliation' },
  { path: '/orders', label: '订单列表' },
  { path: '/refund-records', label: '退款记录' },
  { path: '/hotel-free-verifications', label: '酒店免费核销记录' },
talent-card-admin/src/router/index.ts
@@ -52,7 +52,7 @@
        {
          path: 'upgrade-applications',
          name: 'upgrade-applications',
          component: businessManagementView,
          component: () => import('../views/StaffManagementView.vue'),
          meta: { title: '升级申请', businessKind: 'upgrade' },
        },
        {
@@ -78,12 +78,6 @@
          name: 'merchant-settlement',
          component: () => import('../views/MerchantSettlementView.vue'),
          meta: { title: '商户结算审核' },
        },
        {
          path: 'payment-reconciliation',
          name: 'payment-reconciliation',
          component: () => import('../views/PaymentReconciliationView.vue'),
          meta: { title: 'Payment reconciliation' },
        },
        {
          path: 'products',
@@ -254,9 +248,21 @@
          meta: { title: '角色管理', businessKind: 'role' },
        },
        {
          path: 'roles/new',
          name: 'role-create',
          component: () => import('../views/RoleFormView.vue'),
          meta: { title: '新增角色' },
        },
        {
          path: 'roles/:id',
          name: 'role-edit',
          component: () => import('../views/RoleFormView.vue'),
          meta: { title: '编辑角色' },
        },
        {
          path: 'staff',
          name: 'staff',
          component: businessManagementView,
          component: () => import('../views/StaffManagementView.vue'),
          meta: { title: '员工管理', businessKind: 'staff' },
        },
        {
talent-card-admin/src/style.css
@@ -3635,6 +3635,17 @@
.merchant-location-dialog { display: grid; gap: 14px; }
.merchant-location-dialog p { margin: 0; color: #606266; font-size: 13px; line-height: 1.6; }
.merchant-location-picker { display: grid; gap: 12px; }
.merchant-map-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; }
.merchant-location-map { height: 480px; overflow: hidden; border: 1px solid #dcdfe6; border-radius: 4px; background: #edf1f5; }
.merchant-map-results { display: grid; max-height: 132px; overflow-y: auto; border: 1px solid #e4e7ed; border-radius: 4px; }
.merchant-map-results button { padding: 9px 12px; border: 0; border-bottom: 1px solid #f0f2f5; background: #fff; color: #303133; cursor: pointer; font: inherit; text-align: left; }
.merchant-map-results button:hover { background: #ecf5ff; color: #409eff; }
.merchant-location-coordinates { display: flex; justify-content: space-between; color: #606266; font-size: 13px; }
.merchant-location-coordinates span { color: #909399; }
.merchant-map-marker { background: transparent; border: 0; }
.merchant-map-marker span { display: block; width: 18px; height: 18px; border: 4px solid #fff; border-radius: 50% 50% 50% 0; background: #409eff; box-shadow: 0 1px 5px rgba(0, 0, 0, .35); transform: rotate(-45deg); }
.merchant-map-marker span::after { display: block; width: 6px; height: 6px; margin: 6px; border-radius: 50%; background: #fff; content: ''; }
.product-audit-preview-image { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; object-fit: contain; }
.talent-create-page .detail-block {
talent-card-admin/src/types/admin.ts
@@ -46,6 +46,30 @@
  status?: number
}
export interface PolicyArticle extends NewsArticle {
  policyNo?: string
  subtitle?: string
}
export interface PolicyArticlePayload {
  title: string
  subtitle?: string
  coverUrl?: string
  author?: string
  source?: string
  content?: string
  isTop?: boolean
  sortNo?: number
  status?: number
}
export interface PolicyArticleQuery extends PageQuery {
  title?: string
  author?: string
  publishStart?: string
  publishEnd?: string
}
export interface NewsArticleQuery extends PageQuery {
  title?: string
  source?: string
@@ -87,8 +111,8 @@
  cardLevel?: string
}
export interface AdminRole { id: number | string; roleName: string; roleCode: string; remark?: string; status: number; createdAt?: string }
export interface AdminStaff { id: number | string; account: string; displayName: string; roleCode: string; status: number; createdAt?: string }
export interface AdminRole { id: number | string; roleName: string; roleCode: string; remark?: string; permissionCodes?: string | string[]; status: number; createdAt?: string }
export interface AdminStaff { id: number | string; account: string; displayName: string; mobile?: string; roleCode: string; positionName?: string; customerWechat?: string; status: number; createdAt?: string }
export interface TalentQuery extends PageQuery {
  mobile?: string
@@ -151,9 +175,12 @@
  businessStatus?: string
  longitude?: number
  latitude?: number
  canLoginMerchantApp?: number
  loginAccount?: string
  groupBuyingEnabled?: number
  usageInstructions?: string
  rightsText?: string
  merchantRightsConfig?: Array<Record<string, unknown>> | string
  serviceLabel?: string
  isRecommend?: number
  sortNo?: number
@@ -175,9 +202,13 @@
  businessStatus?: string
  longitude?: number
  latitude?: number
  canLoginMerchantApp?: number
  loginAccount?: string
  loginPassword?: string
  groupBuyingEnabled?: number
  usageInstructions?: string
  rightsText?: string
  merchantRightsConfig?: Array<Record<string, unknown>>
  serviceLabel?: string
  recommend?: number
  sortNo?: number
@@ -249,6 +280,9 @@
  merchantId?: number | string
  merchantName?: string
  contactMobile?: string
  keyword?: string
  type?: string
  status?: number
  createdStart?: string
  createdEnd?: string
}
talent-card-admin/src/views/BusinessManagementView.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { Download, Edit, Plus, Refresh, Search, View } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -17,6 +17,7 @@
import { downloadExcel } from '../utils/excel'
const route = useRoute()
const router = useRouter()
const kind = computed(() => String(route.meta.businessKind || 'upgrade'))
const isUpgrade = computed(() => kind.value === 'upgrade')
const isRole = computed(() => kind.value === 'role')
@@ -64,12 +65,14 @@
function view(row: Record<string, unknown>) { activeRow.value = row; dialogVisible.value = true }
function openCreate() {
  if (isRole.value) { router.push('/roles/new'); return }
  editingId.value = undefined
  Object.assign(form, { name: '', code: '', password: '', remark: '', status: 1 })
  editVisible.value = true
}
function openEdit(row: Record<string, unknown>) {
  if (isRole.value) { router.push(`/roles/${row.id}`); return }
  editingId.value = row.id as number | string
  Object.assign(form, { name: String(row.roleName || row.displayName || ''), code: String(row.roleCode || row.account || ''), password: '', remark: String(row.remark || ''), status: Number(row.status) })
  editVisible.value = true
talent-card-admin/src/views/MerchantCreateView.vue
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import L, { type Map as LeafletMap, type Marker } from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { Minus, Picture, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
  createCoupon as createCouponApi,
  checkMerchantLoginAccount,
  createMerchant,
  createProduct,
    getAdminMerchantDetail,
@@ -42,6 +45,9 @@
const route = useRoute()
const router = useRouter()
const saving = ref(false)
const rightsSaving = ref(false)
const accountChecking = ref(false)
const accountTaken = ref(false)
const imageInputRef = ref<HTMLInputElement>()
const imageUploading = ref(false)
const imageUploadTarget = ref<'merchant' | 'productCover' | 'productImage'>('merchant')
@@ -51,17 +57,11 @@
const activeTab = ref(['settings', 'rights', 'products', 'coupons'].includes(routeTab) ? routeTab : 'settings')
const couponMode = ref<'list' | 'edit'>('list')
const isEditMode = computed(() => route.query.mode === 'edit')
const isFoodMerchant = computed(() => form.merchantType === '餐饮服务')
function queryString(key: string, fallback = '') {
  const value = route.query[key]
  return typeof value === 'string' && value ? value : fallback
}
function queryNumber(key: string, fallback = 0) {
  const value = route.query[key]
  if (typeof value !== 'string' || value === '') return fallback
  const parsed = Number(value)
  return Number.isFinite(parsed) ? parsed : fallback
}
const tabs = [
@@ -72,25 +72,23 @@
]
const form = reactive<MerchantPayload & { createdAt: string }>({
  merchantName: queryString('merchantName', '百杏厨房(万象城店)'),
  merchantType: queryString('merchantType', '餐饮服务'),
  contactMobile: queryString('contactMobile', '0812-2223366'),
  address: queryString('address', '四川省攀枝花市东区攀枝花大道东段298号1幢2楼12号'),
  coverUrl: queryString(
    'coverUrl',
    'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?auto=format&fit=crop&w=640&q=80',
  ),
  createdAt: queryString('createdAt', '2026-06-18 11:34:25'),
  businessStatus: queryString('businessStatus', '营业中'),
  longitude: queryNumber('longitude', 0),
  latitude: queryNumber('latitude', 0),
  groupBuyingEnabled: queryNumber('groupBuyingEnabled', 1),
  usageInstructions: queryString('usageInstructions', ''),
  rightsText: queryString('rightsText', ''),
  serviceLabel: queryString('serviceLabel', '到店消费'),
  recommend: queryNumber('recommend', 1),
  sortNo: queryNumber('sortNo', 0),
  status: queryNumber('status', 1),
  merchantName: '',
  merchantType: queryString('merchantType', ''),
  contactMobile: '',
  address: '',
  coverUrl: '',
  createdAt: '',
  businessStatus: '',
  longitude: undefined,
  latitude: undefined,
  canLoginMerchantApp: 1,
  groupBuyingEnabled: 0,
  usageInstructions: '',
  rightsText: '',
  serviceLabel: '',
  recommend: 0,
  sortNo: 0,
  status: 1,
})
const accountForm = reactive({
@@ -98,6 +96,29 @@
  loginPassword: '',
  confirmPassword: '',
})
function normalizeLoginAccount() {
  accountForm.loginAccount = accountForm.loginAccount.replace(/[^A-Za-z0-9]/g, '').slice(0, 32)
  accountTaken.value = false
}
async function checkLoginAccountAvailable() {
  if (isEditMode.value || !isFoodMerchant.value || Number(form.canLoginMerchantApp) !== 1) return true
  if (!/^[A-Za-z0-9]{1,32}$/.test(accountForm.loginAccount)) return false
  accountChecking.value = true
  try {
    const result = await checkMerchantLoginAccount(accountForm.loginAccount)
    accountTaken.value = !result.available
    if (accountTaken.value) ElMessage.warning('登录账号已存在,请更换')
    return result.available
  } finally {
    accountChecking.value = false
  }
}
function formatDisplayTime(value?: string) {
  return value ? value.replace('T', ' ') : ''
}
const productFilters = reactive({
  productName: '',
@@ -241,12 +262,10 @@
const couponProducts = ref<Product[]>([])
const selectedCouponProductIds = ref<Array<number | string>>([])
const businessHours = reactive<BusinessHour[]>([
  { id: 1, value: '8:00–12:00' },
  { id: 2, value: '15:00–20:00' },
])
const businessHours = reactive<BusinessHour[]>([{ id: 1, value: '' }])
const rightsConfigs = reactive<MerchantRightConfig[]>([
function defaultMerchantRights(): MerchantRightConfig[] {
  return [
  {
    level: '卓越人才卡',
    enabled: true,
@@ -281,7 +300,7 @@
    consumeEnabled: true,
  },
  {
    level: '游客',
    level: '普通游客',
    enabled: true,
    tag: '',
    detail: '',
@@ -291,7 +310,10 @@
    consumeDesc: '',
    consumeEnabled: false,
  },
])
  ]
}
const rightsConfigs = reactive<MerchantRightConfig[]>(defaultMerchantRights())
const rightExampleImage =
  'https://images.unsplash.com/photo-1559847844-5315695dadae?auto=format&fit=crop&w=640&q=80'
@@ -304,6 +326,13 @@
const locationDialogVisible = ref(false)
const pendingLongitude = ref('')
const pendingLatitude = ref('')
const locationSearch = ref('')
const locationSearching = ref(false)
const locationSearchResults = ref<Array<{ name: string; longitude: number; latitude: number }>>([])
const mapElement = ref<HTMLElement>()
let locationMap: LeafletMap | undefined
let locationMarker: Marker | undefined
const mapMarkerIcon = L.divIcon({ className: 'merchant-map-marker', html: '<span></span>', iconSize: [26, 26], iconAnchor: [13, 26] })
function addBusinessHour() {
  businessHours.push({ id: Date.now(), value: '' })
@@ -314,10 +343,66 @@
  businessHours.splice(index, 1)
}
function markLocation() {
function selectedCoordinates() {
  const longitude = Number(pendingLongitude.value)
  const latitude = Number(pendingLatitude.value)
  return Number.isFinite(longitude) && Number.isFinite(latitude) ? { longitude, latitude } : undefined
}
function setMapMarker(latitude: number, longitude: number, center = true) {
  pendingLongitude.value = longitude.toFixed(6)
  pendingLatitude.value = latitude.toFixed(6)
  if (!locationMap) return
  if (!locationMarker) locationMarker = L.marker([latitude, longitude], { icon: mapMarkerIcon }).addTo(locationMap)
  else locationMarker.setLatLng([latitude, longitude])
  if (center) locationMap.setView([latitude, longitude], Math.max(locationMap.getZoom(), 16))
}
async function initializeLocationMap() {
  await nextTick()
  if (!mapElement.value || locationMap) return
  const initial = selectedCoordinates() || { longitude: 101.718637, latitude: 26.582347 }
  locationMap = L.map(mapElement.value, { zoomControl: true }).setView([initial.latitude, initial.longitude], selectedCoordinates() ? 16 : 13)
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; OpenStreetMap contributors' }).addTo(locationMap)
  locationMap.on('click', (event) => setMapMarker(event.latlng.lat, event.latlng.lng, false))
  if (selectedCoordinates()) setMapMarker(initial.latitude, initial.longitude, false)
  window.setTimeout(() => locationMap?.invalidateSize(), 120)
}
async function searchMapLocation() {
  const keyword = locationSearch.value.trim()
  if (!keyword) { ElMessage.warning('请输入地址或地点关键词'); return }
  locationSearching.value = true
  try {
    const response = await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=6&accept-language=zh-CN&q=${encodeURIComponent(keyword)}`)
    if (!response.ok) throw new Error('search failed')
    const records = await response.json() as Array<{ display_name: string; lon: string; lat: string }>
    locationSearchResults.value = records.map((item) => ({ name: item.display_name, longitude: Number(item.lon), latitude: Number(item.lat) })).filter((item) => Number.isFinite(item.longitude) && Number.isFinite(item.latitude))
    if (!locationSearchResults.value.length) ElMessage.info('未找到匹配地点,请调整关键词或直接点击地图标点')
  } catch {
    ElMessage.error('地点搜索失败,请检查网络后重试')
  } finally { locationSearching.value = false }
}
function selectSearchLocation(item: { name: string; longitude: number; latitude: number }) {
  setMapMarker(item.latitude, item.longitude)
  if (!form.address?.trim()) form.address = item.name
  locationSearchResults.value = []
}
function closeLocationMap() {
  locationMap?.remove()
  locationMap = undefined
  locationMarker = undefined
  locationSearchResults.value = []
}
async function markLocation() {
  pendingLongitude.value = String(form.longitude || '')
  pendingLatitude.value = String(form.latitude || '')
  locationDialogVisible.value = true
  locationSearch.value = form.address || ''
  await initializeLocationMap()
}
function saveLocation() {
@@ -330,6 +415,7 @@
  form.longitude = longitude
  form.latitude = latitude
  locationDialogVisible.value = false
  closeLocationMap()
  ElMessage.success('地图位置已标记')
}
@@ -386,8 +472,47 @@
  }
}
function saveRights() {
  ElMessage.success('商家权益已保存')
function resetMerchantRights(config: unknown) {
  const savedRights = parseJsonValue<MerchantRightConfig[]>(config, [])
  rightsConfigs.splice(0, rightsConfigs.length, ...defaultMerchantRights().map((item) => {
    const saved = Array.isArray(savedRights) ? savedRights.find((entry) => entry?.level === item.level) : undefined
    return { ...item, ...(saved || {}) }
  }))
}
async function saveRights() {
  const merchantId = currentMerchantId()
  if (!merchantId) {
    ElMessage.warning('请先保存商家设置')
    return
  }
  rightsSaving.value = true
  try {
    await updateMerchant(merchantId, {
      merchantName: form.merchantName,
      merchantType: form.merchantType,
      contactName: form.contactName,
      contactMobile: form.contactMobile,
      address: form.address,
      coverUrl: form.coverUrl,
      businessStatus: form.businessStatus,
      longitude: Number(form.longitude || 0) || undefined,
      latitude: Number(form.latitude || 0) || undefined,
      canLoginMerchantApp: Number(form.canLoginMerchantApp ?? 1),
      groupBuyingEnabled: form.groupBuyingEnabled,
      usageInstructions: form.usageInstructions,
      rightsText: form.rightsText,
      merchantRightsConfig: rightsConfigs.map((item) => ({ ...item })),
      serviceLabel: form.serviceLabel,
      recommend: form.recommend,
      sortNo: Number(form.sortNo || 0),
      status: form.status,
    })
    ElMessage.success('商家权益已保存')
  } finally {
    rightsSaving.value = false
  }
}
function currentMerchantId() {
@@ -434,8 +559,8 @@
function resetProductForm(row?: Product) {
  editingProductId.value = row?.id
  productCreatedAt.value = row?.createdAt || form.createdAt || ''
  productUpdatedAt.value = row?.submitTime || row?.createdAt || form.createdAt || ''
  productCreatedAt.value = formatDisplayTime(row?.createdAt || form.createdAt)
  productUpdatedAt.value = formatDisplayTime(row?.submitTime || row?.createdAt || form.createdAt)
  const notice = parseJsonValue<Record<string, string>>(row?.purchaseNotice, {})
  productForm.merchantId = row?.merchantId || currentMerchantId()
  productForm.productType = row?.productType || 'GROUP'
@@ -518,6 +643,22 @@
async function saveProduct() {
  if (!productForm.productName?.trim()) {
    ElMessage.warning('请输入商品名称')
    return
  }
  if (productForm.salePrice === undefined || productForm.salePrice === null || !Number.isFinite(Number(productForm.salePrice)) || Number(productForm.salePrice) <= 0) {
    ElMessage.warning('请输入大于 0 的商品金额')
    return
  }
  if (!productForm.coverUrl?.trim()) {
    ElMessage.warning('请上传商品封面图')
    return
  }
  if (productImages.value.length === 0) {
    ElMessage.warning('请至少添加一张商品图片')
    return
  }
  if (!productForm.detailText?.trim()) {
    ElMessage.warning('请输入商品详情')
    return
  }
  productSaving.value = true
@@ -777,6 +918,17 @@
    ElMessage.warning('请输入商家名称')
    return
  }
    if (!isEditMode.value && isFoodMerchant.value && Number(form.canLoginMerchantApp) === 1) {
        if (!/^[A-Za-z0-9]{1,32}$/.test(accountForm.loginAccount)) {
            ElMessage.warning('登录账号仅支持英文和数字,长度不超过32位')
            return
        }
        if (!(await checkLoginAccountAvailable())) return
        if (accountForm.loginPassword.length < 6 || accountForm.loginPassword.length > 64) {
            ElMessage.warning('登录密码长度须为6至64位')
            return
        }
    }
  if (accountForm.loginPassword !== accountForm.confirmPassword) {
    ElMessage.warning('两次输入的密码不一致')
    return
@@ -787,15 +939,20 @@
    const payload = {
      merchantName: form.merchantName,
      merchantType: form.merchantType,
      contactName: form.contactName,
      contactMobile: form.contactMobile,
      address: form.address,
      coverUrl: form.coverUrl,
      businessStatus: form.businessStatus,
        longitude: Number(form.longitude || 0) || undefined,
        latitude: Number(form.latitude || 0) || undefined,
        canLoginMerchantApp: Number(form.canLoginMerchantApp ?? 1),
        loginAccount: !isEditMode.value ? accountForm.loginAccount : undefined,
        loginPassword: !isEditMode.value ? accountForm.loginPassword : undefined,
        groupBuyingEnabled: form.groupBuyingEnabled,
        usageInstructions: form.usageInstructions,
        rightsText: form.rightsText,
        merchantRightsConfig: rightsConfigs.map((item) => ({ ...item })),
        serviceLabel: form.serviceLabel,
      recommend: form.recommend,
      sortNo: Number(form.sortNo || 0),
@@ -832,9 +989,14 @@
    if (isEditMode.value && queryString('merchantId')) {
        getAdminMerchantDetail(queryString('merchantId')).then((merchant) => {
            Object.assign(form, merchant)
            form.createdAt = formatDisplayTime(merchant.createdAt)
            form.groupBuyingEnabled = Number(merchant.groupBuyingEnabled ?? 1)
            form.longitude = Number(merchant.longitude ?? 0)
            form.latitude = Number(merchant.latitude ?? 0)
            form.canLoginMerchantApp = Number(merchant.canLoginMerchantApp ?? 1)
            form.recommend = Number(merchant.isRecommend ?? 0)
            accountForm.loginAccount = merchant.loginAccount || ''
            resetMerchantRights(merchant.merchantRightsConfig)
        })
    }
  if (activeTab.value === 'products') {
@@ -899,21 +1061,23 @@
              <el-input v-model="form.contactMobile" />
            </div>
            <div class="merchant-form-row recommend-row">
                <label>开启团购</label>
                <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
            </div>
            <template v-if="!isFoodMerchant">
                <div class="merchant-form-row recommend-row">
                    <label>开启团购</label>
                    <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
                </div>
            <div class="merchant-form-row">
                <label>服务标签</label>
                <el-input v-model="form.serviceLabel" placeholder="如:到店消费" />
            </div>
                <div class="merchant-form-row">
                    <label>服务标签</label>
                    <el-input v-model="form.serviceLabel" placeholder="如:到店消费" />
                </div>
            <div class="merchant-form-row">
                <label>经纬度</label>
                <el-input v-model="form.longitude" placeholder="经度" />
                <el-input v-model="form.latitude" placeholder="纬度" />
            </div>
                <div class="merchant-form-row">
                    <label>经纬度</label>
                    <el-input v-model="form.longitude" placeholder="经度" />
                    <el-input v-model="form.latitude" placeholder="纬度" />
                </div>
            </template>
          </div>
          <div class="merchant-create-right">
@@ -954,16 +1118,14 @@
          </div>
        </div>
    </section>
    <el-dialog v-model="locationDialogVisible" title="地图标点" width="430px" destroy-on-close>
      <div class="merchant-location-dialog">
        <p>输入商家位置的经纬度后保存,人才端导航将使用此坐标。</p>
        <el-input v-model="pendingLongitude" placeholder="经度,例如 101.718637" />
        <el-input v-model="pendingLatitude" placeholder="纬度,例如 26.582347" />
    <el-dialog v-model="locationDialogVisible" title="选择地址" width="820px" destroy-on-close @closed="closeLocationMap">
      <div class="merchant-location-picker">
        <div class="merchant-map-search"><el-input v-model="locationSearch" placeholder="搜索商家地址或地点" clearable @keyup.enter="searchMapLocation" /><el-button type="primary" :loading="locationSearching" @click="searchMapLocation">搜索</el-button></div>
        <div v-if="locationSearchResults.length" class="merchant-map-results"><button v-for="item in locationSearchResults" :key="`${item.longitude}-${item.latitude}`" type="button" @click="selectSearchLocation(item)">{{ item.name }}</button></div>
        <div ref="mapElement" class="merchant-location-map" />
        <div class="merchant-location-coordinates">已选坐标:{{ pendingLongitude || '-' }}, {{ pendingLatitude || '-' }}<span>可搜索地点或直接点击地图放置标点</span></div>
      </div>
      <template #footer>
        <el-button @click="locationDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="saveLocation">确认标点</el-button>
      </template>
      <template #footer><el-button @click="locationDialogVisible = false">取消</el-button><el-button type="primary" @click="saveLocation">确认</el-button></template>
    </el-dialog>
        <section class="merchant-create-panel">
@@ -981,13 +1143,16 @@
      <section class="merchant-account-panel">
        <h3>账户设置</h3>
        <el-form class="merchant-account-form" :model="accountForm" label-width="72px" label-position="left">
          <el-form-item label="登录账号">
            <el-input v-model="accountForm.loginAccount" />
          <el-form-item v-if="isFoodMerchant" label="登录商户端" label-width="100px">
            <el-switch v-model="form.canLoginMerchantApp" :active-value="1" :inactive-value="0" />
          </el-form-item>
          <el-form-item label="登录密码">
          <el-form-item label="登录账号" :error="accountTaken ? '登录账号已存在,请更换' : ''">
            <el-input v-model="accountForm.loginAccount" maxlength="32" :readonly="isEditMode" :placeholder="isEditMode ? '未配置登录账号' : '仅支持英文和数字'" @input="normalizeLoginAccount" @blur="checkLoginAccountAvailable" />
          </el-form-item>
          <el-form-item v-if="!isEditMode" label="登录密码">
            <el-input v-model="accountForm.loginPassword" type="password" show-password />
          </el-form-item>
          <el-form-item label="确认密码">
          <el-form-item v-if="!isEditMode" label="确认密码">
            <el-input v-model="accountForm.confirmPassword" type="password" show-password />
          </el-form-item>
        </el-form>
@@ -1000,7 +1165,7 @@
      <section class="merchant-rights-panel">
        <div class="merchant-rights-top-row">
          <strong>团单服务</strong>
          <el-switch />
          <el-switch v-model="form.groupBuyingEnabled" :active-value="1" :inactive-value="0" />
        </div>
        <div v-for="item in rightsConfigs" :key="item.level" class="merchant-rights-card">
@@ -1101,7 +1266,7 @@
        </div>
      </section>
      <el-button type="primary" class="merchant-create-save" @click="saveRights">保存</el-button>
      <el-button type="primary" class="merchant-create-save" :loading="rightsSaving" @click="saveRights">保存</el-button>
    </template>
    <template v-else-if="activeTab === 'products'">
@@ -1162,7 +1327,7 @@
              <template #default="{ row }">{{ row.salesCount || '' }}</template>
            </el-table-column>
            <el-table-column label="修改时间" min-width="90">
              <template #default="{ row }">{{ row.submitTime || row.createdAt || '' }}</template>
              <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
            </el-table-column>
            <el-table-column label="状态" min-width="90">
              <template #default="{ row }">{{ productAuditStatusText(row.auditStatus) }}</template>
@@ -1494,7 +1659,7 @@
                </el-table-column>
                <el-table-column prop="salesCount" label="销量" min-width="98" />
                <el-table-column label="修改时间" min-width="98">
                  <template #default="{ row }">{{ row.submitTime || row.createdAt || '' }}</template>
                  <template #default="{ row }">{{ formatDisplayTime(row.submitTime || row.createdAt) }}</template>
                </el-table-column>
                <el-table-column label="状态" min-width="98">
                  <template #default="{ row }">{{ productAuditStatusText(row.auditStatus) }}</template>
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -3,11 +3,17 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMerchantSettlement, reviewMerchantSettlement } from '../api/admin'
type Kind = 'onboardings' | 'bank-cards' | 'withdrawals'
const kind = ref<Kind>('onboardings'); const loading = ref(false); const rows = ref<any[]>([])
const kind = ref<Kind>('onboardings'), loading = ref(false), rows = ref<any[]>([])
const labels: Record<string,string> = { pending: '待审核', approved: '已通过', rejected: '已驳回', processing: '取现处理中', paid: '已到账', failed: '失败' }
const load = async () => { loading.value = true; try { rows.value = (await getMerchantSettlement(kind.value)).records || [] } finally { loading.value = false } }
const text = (s: string) => ({ pending: '待审核', approved: '已通过', rejected: '已驳回', paid: '已到账', failed: '失败' }[s] || s)
const review = async (row: any, status: string) => { const result = await ElMessageBox.prompt(status === 'rejected' || status === 'failed' ? '请输入处理原因' : '可填写处理备注', '确认处理'); await reviewMerchantSettlement(kind.value, row.id, status, result.value || ''); ElMessage.success('处理成功'); load() }
const review = async (row: any, status: string) => {
  let reason = '', providerValue = ''
  if (status === 'approved') { const isOnboarding = kind.value === 'onboardings'; const result = await ElMessageBox.prompt(isOnboarding ? '请输入汇付进件成功后返回的商户 huifu_id' : '请输入汇付绑卡成功后返回的取现卡 token_no', isOnboarding ? '配置汇付商户号' : '配置汇付取现卡序列号', { inputPattern: /^[A-Za-z0-9]{1,32}$/, inputErrorMessage: '请输入有效的汇付标识' }); providerValue = result.value }
  else if (status === 'rejected' || status === 'failed') { const result = await ElMessageBox.prompt('请输入处理原因', '确认处理'); reason = result.value || '' }
  else { await ElMessageBox.confirm('确认发起汇付取现?取现结果将以汇付同步返回或签名回调为准。', '发起取现', { type: 'warning' }) }
  await reviewMerchantSettlement(kind.value, row.id, status, reason, providerValue); ElMessage.success(status === 'paid' ? '已提交汇付取现' : '处理成功'); load()
}
onMounted(load)
</script>
<template><div class="page-stack"><el-card><div class="toolbar"><el-radio-group v-model="kind" @change="load"><el-radio-button label="onboardings">商户进件</el-radio-button><el-radio-button label="bank-cards">银行卡审核</el-radio-button><el-radio-button label="withdrawals">提现审核</el-radio-button></el-radio-group><el-button @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" border><el-table-column prop="merchantName" label="商户"/><el-table-column prop="storeName" label="门店"/><el-table-column prop="applicationNo" label="申请单号"/><el-table-column prop="bankName" label="银行"/><el-table-column label="金额"><template #default="{row}">{{row.amountCents == null ? '-' : '¥' + (Number(row.amountCents)/100).toFixed(2)}}</template></el-table-column><el-table-column label="状态"><template #default="{row}">{{text(row.status)}}</template></el-table-column><el-table-column label="操作"><template #default="{row}"><template v-if="row.status==='pending'"><el-button size="small" type="primary" @click="review(row,kind==='withdrawals'?'paid':'approved')">通过</el-button><el-button size="small" type="danger" @click="review(row,kind==='withdrawals'?'failed':'rejected')">驳回</el-button></template></template></el-table-column></el-table></el-card></div></template>
<template><div class="page-stack"><el-card><div class="toolbar"><el-radio-group v-model="kind" @change="load"><el-radio-button label="onboardings">商户进件</el-radio-button><el-radio-button label="bank-cards">银行卡审核</el-radio-button><el-radio-button label="withdrawals">提现审核</el-radio-button></el-radio-group><el-button @click="load">刷新</el-button></div><el-table v-loading="loading" :data="rows" border><el-table-column prop="merchantName" label="商户"/><el-table-column prop="storeName" label="门店"/><el-table-column prop="applicationNo" label="申请单号"/><el-table-column prop="bankName" label="银行"/><el-table-column label="金额"><template #default="{row}">{{row.amountCents == null ? '-' : '¥' + (Number(row.amountCents)/100).toFixed(2)}}</template></el-table-column><el-table-column label="状态"><template #default="{row}">{{labels[row.status] || row.status}}</template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><template v-if="row.status==='pending'"><el-button size="small" type="primary" @click="review(row,kind==='withdrawals'?'paid':'approved')">{{kind==='withdrawals'?'发起取现':'通过'}}</el-button><el-button size="small" type="danger" @click="review(row,kind==='withdrawals'?'failed':'rejected')">驳回</el-button></template></template></el-table-column></el-table></el-card></div></template>
<style scoped>.toolbar{display:flex;justify-content:space-between;margin-bottom:18px}</style>
talent-card-admin/src/views/MerchantView.vue
@@ -15,7 +15,7 @@
const merchants = ref<Merchant[]>([])
const selectedCreateType = ref('餐饮服务')
const createMerchantTypes = ['餐饮服务', '电影院', '大剧院', '酒店服务']
const createMerchantTypes = ['餐饮服务', '电影院', '大剧院', '酒店服务', '景区服务']
const merchantTypes = [
  '餐饮服务',
  '电影院',
@@ -83,6 +83,10 @@
function displayDash(value?: string | number) {
  if (value === undefined || value === null || value === '') return '-'
  return value
}
function formatDisplayTime(value?: string) {
  return value ? value.replace('T', ' ') : '-'
}
async function loadData() {
@@ -215,7 +219,7 @@
      <el-form class="merchant-filter-form" :model="filters" label-width="74px">
        <el-form-item label="商家">
          <el-input v-model="filters.keyword" placeholder="请输入商家名称" clearable />
          <el-input v-model="filters.keyword" placeholder="请输入商家名称,支持模糊查询" clearable @keyup.enter="search" />
        </el-form-item>
        <el-form-item label="联系电话">
          <el-input v-model="filters.contactMobile" placeholder="请输入商家联系电话" clearable />
@@ -284,7 +288,9 @@
        <el-table-column prop="reservationCount" label="预约总数" width="76" align="center">
          <template #default="{ row }">{{ displayDash(row.reservationCount) }}</template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间" width="120" align="center" />
        <el-table-column label="创建时间" width="140" align="center">
          <template #default="{ row }">{{ formatDisplayTime(row.createdAt) }}</template>
        </el-table-column>
        <el-table-column label="是否推荐" width="82" align="center">
          <template #default="{ row }">{{ recommendText(row.isRecommend) }}</template>
        </el-table-column>
talent-card-admin/src/views/PaymentReconciliationView.vue
File was deleted
talent-card-admin/src/views/RoleFormView.vue
New file
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { createAdminRole, getAdminRoles, updateAdminRole } from '../api/admin'
import type { AdminRole } from '../types/admin'
type Page = { code: string; name: string; actions: string[] }
type Module = { name: string; pages: Page[] }
const modules: Module[] = [
  { name: '首页', pages: [{ code: 'dashboard', name: '首页', actions: ['查看'] }] },
  { name: '账户权限管理', pages: [{ code: 'staff', name: '账号管理', actions: ['查看', '新增', '编辑', '启用/禁用', '删除', '重置密码'] }, { code: 'roles', name: '角色管理', actions: ['查看', '新增', '编辑', '删除'] }] },
  { name: '持卡管理', pages: [{ code: 'talents', name: '持卡人管理', actions: ['查看', '新增', '编辑', '启用/禁用'] }, { code: 'family-cards', name: '家属卡管理', actions: ['查看', '审核'] }] },
  { name: '商家管理', pages: [{ code: 'merchants', name: '商家查询', actions: ['查看', '新增', '编辑'] }, { code: 'products', name: '团单审核', actions: ['查看', '审核'] }] },
  { name: '订单管理', pages: [{ code: 'orders', name: '订单列表', actions: ['查看', '退款处理'] }, { code: 'refunds', name: '退款记录', actions: ['查看'] }] },
  { name: '预约管理', pages: [{ code: 'medical-reservations', name: '体检预约管理', actions: ['查看', '审核'] }, { code: 'parking-plates', name: '停车车牌管理', actions: ['查看'] }] },
  { name: '运营管理', pages: [{ code: 'equity-images', name: '权益图片管理', actions: ['查看', '编辑'] }, { code: 'news', name: '人才资讯管理', actions: ['查看', '新增', '编辑'] }] },
  { name: '系统管理', pages: [{ code: 'settings', name: '平台设置', actions: ['查看', '编辑'] }] },
]
const route = useRoute(), router = useRouter(), saving = ref(false)
const id = computed(() => route.params.id ? String(route.params.id) : '')
const form = reactive({ roleName: '', remark: '', status: 1, permissionCodes: [] as string[] })
const code = (page: string, action: string) => `admin.${page}.${action === '启用/禁用' ? 'disable' : action === '重置密码' ? 'reset' : action === '退款处理' ? 'refund' : action === '查看' ? 'view' : action === '新增' ? 'create' : action === '编辑' ? 'edit' : action === '删除' ? 'delete' : 'audit'}`
const pageCodes = (page: Page) => page.actions.map((action) => code(page.code, action))
const moduleCodes = (module: Module) => module.pages.flatMap(pageCodes)
const checked = (values: string[]) => values.every((value) => form.permissionCodes.includes(value))
const partial = (values: string[]) => values.some((value) => form.permissionCodes.includes(value)) && !checked(values)
const toggle = (values: string[], enabled: boolean) => { const selected = new Set(form.permissionCodes); values.forEach((value) => enabled ? selected.add(value) : selected.delete(value)); form.permissionCodes = [...selected] }
const parse = (value: AdminRole['permissionCodes']) => { if (Array.isArray(value)) return value; try { const result = JSON.parse(value || '[]'); return Array.isArray(result) ? result : [] } catch { return [] } }
async function load() { if (!id.value) return; const role = (await getAdminRoles()).find((item) => String(item.id) === id.value); if (!role) { ElMessage.error('角色不存在'); router.replace('/roles'); return }; Object.assign(form, { roleName: role.roleName, remark: role.remark || '', status: role.status, permissionCodes: parse(role.permissionCodes) }) }
async function save() { if (!form.roleName.trim()) { ElMessage.warning('请输入角色名称'); return }; saving.value = true; try { const payload = { roleName: form.roleName.trim(), roleCode: '', remark: form.remark.trim(), status: form.status, permissionCodes: form.permissionCodes }; if (id.value) await updateAdminRole(id.value, payload); else await createAdminRole(payload); ElMessage.success('保存成功'); router.replace('/roles') } finally { saving.value = false } }
onMounted(load)
</script>
<template><div class="page-stack role-form"><section class="panel"><div class="panel-header"><h3>{{ id ? '编辑角色' : '新增角色' }}</h3></div><el-form class="base" label-position="top"><el-form-item label="角色名称" required><el-input v-model="form.roleName" placeholder="请输入" maxlength="64" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入" maxlength="255" /></el-form-item></el-form><div class="permission-title">操作权限</div><div class="matrix"><div class="line header"><span>板块</span><span>页面</span><span>权限</span></div><template v-for="module in modules" :key="module.name"><div v-for="(page, index) in module.pages" :key="page.code" class="line"><span v-if="index === 0" class="module" :style="{ gridRow: `span ${module.pages.length}` }"><el-checkbox :model-value="checked(moduleCodes(module))" :indeterminate="partial(moduleCodes(module))" @change="toggle(moduleCodes(module), Boolean($event))">{{ module.name }}</el-checkbox></span><span><el-checkbox :model-value="checked(pageCodes(page))" :indeterminate="partial(pageCodes(page))" @change="toggle(pageCodes(page), Boolean($event))">{{ page.name }}</el-checkbox></span><span class="actions"><el-checkbox v-for="action in page.actions" :key="action" :model-value="form.permissionCodes.includes(code(page.code, action))" @change="toggle([code(page.code, action)], Boolean($event))">{{ action }}</el-checkbox></span></div></template></div><div class="buttons"><el-button type="primary" :loading="saving" @click="save">保存</el-button><el-button @click="router.back()">返回</el-button></div></section></div></template>
<style scoped>.base{display:grid;grid-template-columns:360px minmax(400px,1fr);gap:28px;padding:16px 20px}.permission-title{padding:14px 20px;border-top:8px solid #f4f6f8;font-size:16px;font-weight:600}.matrix{margin:0 20px;border:1px solid #dfe4ea}.line{display:grid;grid-template-columns:150px 190px 1fr;min-height:44px;border-bottom:1px solid #e7ebf0}.line:last-child{border-bottom:0}.line>span{display:flex;align-items:center;padding:7px 18px;border-right:1px solid #e7ebf0}.line>span:last-child{border-right:0}.header{min-height:36px;background:#fafbfd;font-weight:600}.header span{justify-content:center}.actions{gap:26px;flex-wrap:wrap}.buttons{padding:20px;display:flex;gap:14px}.buttons .el-button{min-width:78px}@media(max-width:850px){.base{grid-template-columns:1fr}.line{grid-template-columns:120px 150px 1fr}.actions{gap:12px}}</style>
talent-card-admin/src/views/StaffManagementView.vue
New file
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { createAdminStaff, deleteAdminStaff, getAdminRoles, getAdminStaff, resetAdminStaffPassword, updateAdminStaff } from '../api/admin'
import type { AdminRole, AdminStaff } from '../types/admin'
const loading = ref(false), visible = ref(false), saving = ref(false), editId = ref<number | string>()
const rows = ref<AdminStaff[]>([]), roles = ref<AdminRole[]>([])
const filters = reactive({ name: '', mobile: '', role: '', status: '' })
const form = reactive({ displayName: '', mobile: '', account: '', password: '', roleCode: '', positionName: '', customerWechat: '', status: 1 })
const filtered = computed(() => rows.value.filter(r => (!filters.name || r.displayName.includes(filters.name)) && (!filters.mobile || (r.mobile || '').includes(filters.mobile)) && (!filters.role || r.roleCode === filters.role) && (!filters.status || String(r.status) === filters.status)))
const roleName = (code: string) => roles.value.find(r => r.roleCode === code)?.roleName || code
async function load() { loading.value = true; try { const result = await Promise.all([getAdminStaff(), getAdminRoles()]); rows.value = result[0]; roles.value = result[1].filter(r => r.status === 1) } finally { loading.value = false } }
function reset() { Object.assign(filters, { name: '', mobile: '', role: '', status: '' }) }
function create() { editId.value = undefined; Object.assign(form, { displayName: '', mobile: '', account: '', password: '', roleCode: roles.value[0]?.roleCode || '', positionName: '', customerWechat: '', status: 1 }); visible.value = true }
function edit(row: AdminStaff) { editId.value = row.id; Object.assign(form, { displayName: row.displayName, mobile: row.mobile || '', account: row.account, password: '', roleCode: row.roleCode, positionName: row.positionName || '', customerWechat: row.customerWechat || '', status: row.status }); visible.value = true }
async function save() { if (!form.displayName.trim() || !form.account.trim() || !form.roleCode || !form.positionName.trim()) return ElMessage.warning('请填写人员名称、登录账号、角色和职务'); if (!editId.value && form.password.length < 6) return ElMessage.warning('新建员工密码至少 6 位'); saving.value = true; try { const payload = { ...form, password: form.password || undefined }; if (editId.value) await updateAdminStaff(editId.value, payload); else await createAdminStaff({ ...payload, password: form.password }); ElMessage.success('保存成功'); visible.value = false; load() } finally { saving.value = false } }
async function changeStatus(row: AdminStaff) { await updateAdminStaff(row.id, { displayName: row.displayName, mobile: row.mobile || '', account: row.account, roleCode: row.roleCode, positionName: row.positionName || '未设置', customerWechat: row.customerWechat || '', status: row.status === 1 ? 0 : 1 }); ElMessage.success(row.status === 1 ? '已禁用' : '已启用'); load() }
async function password(row: AdminStaff) { const result = await ElMessageBox.prompt('请输入 6-64 位新密码', `重置 ${row.displayName} 的密码`, { inputType: 'password', inputPattern: /^.{6,64}$/, inputErrorMessage: '密码长度为 6-64 位' }); await resetAdminStaffPassword(row.id, result.value); ElMessage.success('密码已重置') }
async function remove(row: AdminStaff) { await ElMessageBox.confirm(`确认删除员工“${row.displayName}”?`, '删除员工', { type: 'warning' }); await deleteAdminStaff(row.id); ElMessage.success('已删除'); load() }
onMounted(load)
</script>
<template><div class="page-stack"><section class="panel"><div class="panel-header"><h3>账号</h3></div><el-form class="filters" inline><el-form-item label="用户名称"><el-input v-model="filters.name" /></el-form-item><el-form-item label="用户电话"><el-input v-model="filters.mobile" /></el-form-item><el-form-item label="角色"><el-select v-model="filters.role" clearable placeholder="请选择"><el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.roleCode" /></el-select></el-form-item><el-form-item label="账号状态"><el-select v-model="filters.status" clearable placeholder="请选择"><el-option label="启用" value="1" /><el-option label="禁用" value="0" /></el-select></el-form-item><el-form-item><el-button :icon="Refresh" @click="reset">重置</el-button><el-button type="primary" :icon="Search">搜索</el-button></el-form-item></el-form><div class="tools"><el-button type="primary" :icon="Plus" @click="create">新增</el-button></div><el-table v-loading="loading" :data="filtered" stripe class="table"><el-table-column type="index" label="序号" width="60"/><el-table-column prop="displayName" label="人员名称" min-width="96"/><el-table-column prop="mobile" label="联系电话" min-width="118"/><el-table-column prop="account" label="登录账号" min-width="116"/><el-table-column label="角色" min-width="110"><template #default="{row}">{{ roleName(row.roleCode) }}</template></el-table-column><el-table-column prop="positionName" label="职务" min-width="92"/><el-table-column prop="customerWechat" label="客服微信" min-width="116"/><el-table-column label="状态" width="78"><template #default="{row}">{{row.status === 1 ? '启用' : '禁用'}}</template></el-table-column><el-table-column prop="createdAt" label="创建时间" min-width="155"/><el-table-column label="操作" width="225" fixed="right"><template #default="{row}"><el-button link type="primary" @click="edit(row)">编辑</el-button><el-button link type="primary" @click="changeStatus(row)">{{row.status === 1 ? '禁用' : '启用'}}</el-button><el-button link type="primary" @click="password(row)">重置密码</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column></el-table></section><el-dialog v-model="visible" :title="editId ? '编辑员工' : '新增员工'" width="560px" destroy-on-close><el-form label-width="92px"><el-form-item label="人员名称" required><el-input v-model="form.displayName"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.mobile" maxlength="11"/></el-form-item><el-form-item label="登录账号" required><el-input v-model="form.account"/></el-form-item><el-form-item label="登录密码" :required="!editId"><el-input v-model="form.password" type="password" :placeholder="editId ? '留空则不修改' : '至少 6 位'"/></el-form-item><el-form-item label="角色" required><el-select v-model="form.roleCode" class="full"><el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.roleCode"/></el-select></el-form-item><el-form-item label="职务" required><el-input v-model="form.positionName"/></el-form-item><el-form-item label="客服微信"><el-input v-model="form.customerWechat"/></el-form-item><el-form-item label="账号状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用"/></el-form-item></el-form><template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template></el-dialog></div></template>
<style scoped>.filters{margin:14px 20px 0;padding:16px;border:1px solid #dce3ea;border-radius:4px}.filters :deep(.el-input),.filters :deep(.el-select){width:130px}.tools{display:flex;justify-content:flex-end;padding:10px 20px 14px}.table{margin:0 20px;width:calc(100% - 40px)}.full{width:100%}</style>
talent-card-admin/src/views/TalentArticleManagementView.vue
@@ -1,272 +1,70 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getAdminNews, updateAdminNewsStatus } from '../api/admin'
import type { NewsArticle } from '../types/admin'
import { getAdminNews, getAdminPolicies, updateAdminNewsStatus, updateAdminPolicyStatus } from '../api/admin'
import type { NewsArticle, PolicyArticle } from '../types/admin'
interface ArticleRow {
  id: string
  title: string
  publisher: string
  publishTime: string
  views: number
  status: string
  statusCode?: number
}
interface PageConfig {
  kind: 'news' | 'policy'
  titleLabel: string
  titlePlaceholder: string
  publishPath: string
  editPath: string
  rows: ArticleRow[]
}
const route = useRoute()
const router = useRouter()
const route = useRoute(); const router = useRouter()
const isPolicy = computed(() => route.meta.businessKind === 'talentPolicyHome')
const loading = ref(false)
const rows = ref<Array<NewsArticle | PolicyArticle>>([])
const filters = reactive({ title: '', author: '', status: undefined as number | undefined, publishRange: [] as string[] })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const filters = reactive({
  title: '',
  publisher: '',
  status: '',
  publishRange: [] as string[],
})
function formatTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
function statusText(status?: number) { return status === 1 ? '已发布' : status === 2 ? '已下架' : '草稿' }
const pagination = reactive({
  page: 1,
  size: 10,
  total: 0,
})
const newsRows = ref<ArticleRow[]>([])
const configs: Record<string, PageConfig> = {
  talentNewsHome: {
    kind: 'news',
    titleLabel: '资讯标题',
    titlePlaceholder: '资讯标题',
    publishPath: '/talent-news',
    editPath: '/talent-news-categories',
    rows: [
      {
        id: '123464561',
        title: '攀枝花市2026年上半年公开考试招聘中小学教师东区岗位面试资格复审及领取面试通知书公告',
        publisher: '赵四',
        publishTime: '2026-06-30 18:01:57',
        views: 123,
        status: '正常',
      },
    ],
  },
  talentPolicyHome: {
    kind: 'policy',
    titleLabel: '政策标题',
    titlePlaceholder: '资讯标题',
    publishPath: '/talent-policies',
    editPath: '/talent-policy-categories',
    rows: [
      {
        id: '123464561',
        title: '攀枝花市2026年上半年公开考试招聘中小学教师东区岗位面试资格复审及领取面试通知书公告',
        publisher: '赵四',
        publishTime: '2026-06-30 18:01:57',
        views: 123,
        status: '正常',
      },
    ],
  },
}
const config = computed(() => configs[String(route.meta.businessKind || 'talentNewsHome')])
const isNews = computed(() => config.value.kind === 'news')
const rows = computed(() => {
  if (isNews.value) return newsRows.value
  const titleKeyword = filters.title.trim()
  const publisherKeyword = filters.publisher.trim()
  return config.value.rows.filter((row) => {
    const titleMatched = !titleKeyword || row.title.includes(titleKeyword)
    const publisherMatched = !publisherKeyword || row.publisher.includes(publisherKeyword)
    const statusMatched = !filters.status || row.status === filters.status
    return titleMatched && publisherMatched && statusMatched
  })
})
function formatTime(value?: string) {
  return value ? value.replace('T', ' ').slice(0, 19) : '-'
}
function mapNewsArticle(item: NewsArticle): ArticleRow {
  const status = Number(item.status ?? 0)
  return {
    id: String(item.id),
    title: item.title,
    publisher: item.source || item.author || '-',
    publishTime: formatTime(item.publishTime),
    views: Number(item.viewCount || 0),
    status: status === 1 ? '已发布' : status === 2 ? '已下架' : '草稿',
    statusCode: status,
  }
}
async function loadNews() {
async function load() {
  loading.value = true
  try {
    const [publishStart, publishEnd] = filters.publishRange
    const data = await getAdminNews({
      title: filters.title || undefined,
      source: filters.publisher || undefined,
      status: filters.status ? Number(filters.status) : undefined,
      publishStart,
      publishEnd,
      page: pagination.page,
      size: pagination.size,
    })
    newsRows.value = data.records.map(mapNewsArticle)
    pagination.total = data.total
  } finally {
    loading.value = false
  }
    const result = isPolicy.value
      ? await getAdminPolicies({ title: filters.title || undefined, author: filters.author || undefined, status: filters.status, publishStart, publishEnd, page: pagination.page, size: pagination.size })
      : await getAdminNews({ title: filters.title || undefined, source: filters.author || undefined, status: filters.status, publishStart, publishEnd, page: pagination.page, size: pagination.size })
    rows.value = result.records
    pagination.total = result.total
  } finally { loading.value = false }
}
async function search() {
  if (isNews.value) {
    pagination.page = 1
    await loadNews()
    return
  }
  loading.value = true
  window.setTimeout(() => {
    loading.value = false
  }, 160)
async function search() { pagination.page = 1; await load() }
async function reset() { filters.title = ''; filters.author = ''; filters.status = undefined; filters.publishRange = []; pagination.page = 1; await load() }
function createArticle() { router.push(isPolicy.value ? '/talent-policies' : '/talent-news') }
function editArticle(row: NewsArticle | PolicyArticle) { router.push(isPolicy.value ? `/talent-policy-categories/${row.id}` : `/talent-news-categories/${row.id}`) }
async function toggleStatus(row: NewsArticle | PolicyArticle) {
  const next = Number(row.status) === 1 ? 2 : 1
  await ElMessageBox.confirm(next === 1 ? '确认发布该政策吗?' : '确认下架该政策吗?', '操作确认', { type: 'warning' })
  if (isPolicy.value) await updateAdminPolicyStatus(row.id, next)
  else await updateAdminNewsStatus(row.id, next)
  ElMessage.success(next === 1 ? (isPolicy.value ? '政策已发布' : '资讯已发布') : (isPolicy.value ? '政策已下架' : '资讯已下架'))
  await load()
}
async function resetFilters() {
  filters.title = ''
  filters.publisher = ''
  filters.status = ''
  filters.publishRange = []
  if (isNews.value) {
    pagination.page = 1
    await loadNews()
  }
}
function createArticle() {
  router.push(config.value.publishPath)
}
async function disableArticle(row: ArticleRow) {
  if (isNews.value) {
    const nextStatus = row.statusCode === 1 ? 2 : 1
    await updateAdminNewsStatus(row.id, nextStatus)
    ElMessage.success(nextStatus === 1 ? '资讯已发布' : '资讯已下架')
    await loadNews()
    return
  }
  row.status = row.status === '正常' ? '禁用' : '正常'
  ElMessage.success(row.status === '禁用' ? '已禁用' : '已启用')
}
function editArticle(row: ArticleRow) {
  router.push(isNews.value ? `${config.value.editPath}/${row.id}` : config.value.editPath)
}
function handlePageChange(page: number) {
  pagination.page = page
  if (isNews.value) void loadNews()
}
onMounted(() => {
  if (isNews.value) void loadNews()
})
watch(() => route.fullPath, () => {
  if (isNews.value) void loadNews()
})
onMounted(() => void load())
watch(() => route.fullPath, () => void load())
</script>
<template>
  <div class="article-management-page">
    <section class="article-management-panel">
      <h3>查询表格</h3>
      <el-form class="article-management-filter-form" :model="filters" label-width="72px" label-position="left">
        <el-form-item :label="config.titleLabel">
          <el-input v-model="filters.title" :placeholder="config.titlePlaceholder" clearable />
        </el-form-item>
        <el-form-item label="发布时间">
          <el-date-picker
            v-model="filters.publishRange"
            type="daterange"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            range-separator="至"
            value-format="YYYY-MM-DD"
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="filters.status" placeholder="全部" clearable>
            <el-option label="全部" value="" />
            <el-option :label="isNews ? '已发布' : '正常'" :value="isNews ? '1' : '正常'" />
            <el-option :label="isNews ? '草稿' : '禁用'" :value="isNews ? '0' : '禁用'" />
            <el-option v-if="isNews" label="已下架" value="2" />
          </el-select>
        </el-form-item>
        <el-form-item label="发布人">
          <el-input v-model="filters.publisher" placeholder="请输入发布人名称" clearable />
        </el-form-item>
        <div class="article-management-filter-actions">
          <el-button type="primary" :icon="Search" @click="search">查询</el-button>
          <el-button :icon="Refresh" @click="resetFilters">重置</el-button>
        </div>
        <el-form-item :label="isPolicy ? '政策标题' : '资讯标题'"><el-input v-model="filters.title" :placeholder="isPolicy ? '请输入政策标题' : '请输入资讯标题'" clearable /></el-form-item>
        <el-form-item label="发布时间"><el-date-picker v-model="filters.publishRange" type="daterange" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" value-format="YYYY-MM-DD" /></el-form-item>
        <el-form-item label="状态"><el-select v-model="filters.status" placeholder="全部" clearable><el-option label="草稿" :value="0" /><el-option label="已发布" :value="1" /><el-option label="已下架" :value="2" /></el-select></el-form-item>
        <el-form-item label="发布人"><el-input v-model="filters.author" placeholder="请输入发布人名称" clearable /></el-form-item>
        <div class="article-management-filter-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="Refresh" @click="reset">重置</el-button></div>
      </el-form>
      <div class="article-management-tools">
        <el-button type="primary" @click="createArticle">新增</el-button>
      </div>
      <el-table
        v-loading="loading"
        :data="rows"
        border
        class="article-management-table"
        empty-text="暂无数据"
      >
        <el-table-column prop="id" label="资讯id" width="150" align="center" />
        <el-table-column prop="title" :label="config.titleLabel" min-width="190" show-overflow-tooltip />
        <el-table-column prop="publisher" label="发布人" width="114" align="center" />
        <el-table-column prop="publishTime" label="发布时间" width="148" align="center" />
        <el-table-column prop="views" label="观看数" width="120" align="center" />
        <el-table-column prop="status" label="状态" width="100" align="center" />
        <el-table-column label="操作" width="190" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="disableArticle(row)">
              {{ isNews ? (row.statusCode === 1 ? '下架' : '发布') : (row.status === '正常' ? '禁用' : '启用') }}
            </el-button>
            <el-button link type="primary" @click="editArticle(row)">编辑</el-button>
          </template>
        </el-table-column>
      <div class="article-management-tools"><el-button type="primary" @click="createArticle">新增</el-button></div>
      <el-table v-loading="loading" :data="rows" border class="article-management-table" empty-text="暂无数据">
        <el-table-column prop="id" :label="isPolicy ? '政策 ID' : '资讯 ID'" width="150" align="center" />
        <el-table-column prop="title" :label="isPolicy ? '政策标题' : '资讯标题'" min-width="220" show-overflow-tooltip />
        <el-table-column prop="author" label="发布人" width="130" align="center" />
        <el-table-column label="发布时间" width="170" align="center"><template #default="{ row }">{{ formatTime(row.publishTime) }}</template></el-table-column>
        <el-table-column prop="viewCount" label="观看数" width="100" align="center" />
        <el-table-column label="状态" width="100" align="center"><template #default="{ row }">{{ statusText(row.status) }}</template></el-table-column>
        <el-table-column label="操作" width="180" align="center"><template #default="{ row }"><el-button link type="primary" @click="toggleStatus(row)">{{ Number(row.status) === 1 ? '下架' : '发布' }}</el-button><el-button link type="primary" @click="editArticle(row)">编辑</el-button></template></el-table-column>
      </el-table>
      <div class="article-management-pagination">
        <el-pagination
          v-model:current-page="pagination.page"
          v-model:page-size="pagination.size"
          layout="total, prev, pager, next"
          :pager-count="7"
          :total="pagination.total"
          @current-change="handlePageChange"
        />
      </div>
      <div class="article-management-pagination"><el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.size" layout="total, prev, pager, next" :total="pagination.total" @current-change="load" /></div>
    </section>
  </div>
</template>
talent-card-admin/src/views/TalentCreateView.vue
@@ -15,7 +15,7 @@
const pendingSourceAreaType = ref('LOCAL')
const pendingTalentCardNo = ref('')
const levels = ['卓越人才卡', '杰出人才卡', '普通人才卡', '普通游客']
const levels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const form = reactive<TalentPayload>({
  name: '',
@@ -32,6 +32,10 @@
function normalizeIdCardNo(value: string) {
  form.idCardNo = value.toUpperCase().replace(/[^0-9A-Z]/g, '').slice(0, 18)
}
function normalizeMobile(value: string) {
  form.mobile = value.replace(/\D/g, '').slice(0, 11)
}
function openCardDialog() {
@@ -63,16 +67,28 @@
}
async function submitCreate() {
  if (!form.mobile?.trim()) {
    ElMessage.warning('请输入手机号码')
  if (!/^1\d{10}$/.test(form.mobile || '')) {
    ElMessage.warning('请输入11位数字手机号码')
    return
  }
  if (!form.name.trim()) {
    ElMessage.warning('请输入持卡人姓名')
    return
  }
  if (form.idCardNo && !/^[0-9A-Z]{18}$/.test(form.idCardNo)) {
  if (!/^[0-9A-Z]{18}$/.test(form.idCardNo || '')) {
    ElMessage.warning('身份证号须为 18 位数字或大写英文字母')
    return
  }
  if (!form.contactAddress?.trim()) {
    ElMessage.warning('请输入联系地址')
    return
  }
  if (!form.workAddress?.trim()) {
    ElMessage.warning('请输入工作地址')
    return
  }
  if (form.gender !== 1 && form.gender !== 2) {
    ElMessage.warning('请选择性别')
    return
  }
  if (!form.talentLevel) {
@@ -83,13 +99,17 @@
    ElMessage.warning('请确认人才等级、来源地和人才卡号')
    return
  }
  if (!serviceStaff.value) {
    ElMessage.warning('请选择服务专员')
    return
  }
  saving.value = true
  try {
    const talent = await createTalent(form)
    if (serviceStaff.value) await updateTalentAdvisor(talent.id, serviceStaff.value)
    await updateTalentAdvisor(talent.id, serviceStaff.value)
    ElMessage.success('持卡人已保存')
    router.push(`/talents/${talent.id}`)
    router.push('/talents')
  } finally {
    saving.value = false
  }
@@ -107,7 +127,13 @@
    <section class="detail-block create-mobile-summary">
      <el-form :model="form" label-width="86px">
        <el-form-item label="手机号码:">
          <el-input v-model="form.mobile" placeholder="请输入" />
          <el-input
            v-model="form.mobile"
            placeholder="请输入"
            inputmode="numeric"
            maxlength="11"
            @input="normalizeMobile"
          />
        </el-form-item>
      </el-form>
    </section>
@@ -128,7 +154,13 @@
            />
          </el-form-item>
          <el-form-item label="联系方式:">
            <el-input v-model="form.mobile" placeholder="请输入" />
            <el-input
              v-model="form.mobile"
              placeholder="请输入"
              inputmode="numeric"
              maxlength="11"
              @input="normalizeMobile"
            />
          </el-form-item>
          <el-form-item label="联系地址:">
            <el-input v-model="form.contactAddress" placeholder="请输入" />
talent-card-admin/src/views/TalentDetailView.vue
@@ -38,7 +38,7 @@
const levelDialogVisible = ref(false)
const levelSaving = ref(false)
const pendingTalentLevel = ref('')
const talentLevels = ['卓越人才卡', '杰出人才卡', '普通人才卡', '普通游客']
const talentLevels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const primaryCard = computed(() => cards.value[0])
talent-card-admin/src/views/TalentListView.vue
@@ -15,7 +15,7 @@
const importResult = ref<TalentImportResult>()
const talents = ref<Talent[]>([])
const levels = ['卓越人才卡', '杰出人才卡', '普通人才卡', '普通游客']
const levels = ['卓越人才卡', '杰出人才卡', '菁英人才卡', '普通游客']
const filters = reactive({
  keyword: '',
@@ -50,6 +50,10 @@
function areaText(type?: string) {
  return type === 'OUTSIDE' ? '外地' : '本地'
}
function formatDateTime(value?: string) {
  return value ? value.replace('T', ' ') : '-'
}
async function loadData() {
@@ -246,7 +250,9 @@
        <el-table-column prop="orderCount" label="订单数" width="92" align="center">
          <template #default="{ row }">{{ row.orderCount || 0 }}</template>
        </el-table-column>
        <el-table-column prop="registerTime" label="注册时间" width="170" />
        <el-table-column label="注册时间" width="170">
          <template #default="{ row }">{{ formatDateTime(row.registerTime) }}</template>
        </el-table-column>
        <el-table-column label="状态" width="86" align="center">
          <template #default="{ row }">{{ statusText(row.status) }}</template>
        </el-table-column>
@@ -274,7 +280,7 @@
      <div class="talent-import-dialog">
        <div class="talent-import-guide">
          <strong>导入说明</strong>
          <span>请先下载模板并按表头填写,单次最多导入 1000 条,仅支持 .xlsx 文件。</span>
          <span>模板中的 11 项均为必填;服务专员须填写已启用的服务专员姓名。单次最多导入 1000 条,仅支持 .xlsx 文件。</span>
          <el-button link type="primary" :icon="Download" @click="downloadImportTemplate">下载导入模板</el-button>
        </div>
talent-card-admin/src/views/TalentPolicyEditView.vue
@@ -1,102 +1,19 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeftBold, ArrowRightBold, Link, Picture } from '@element-plus/icons-vue'
import { getAdminPolicyDetail, updateAdminPolicy } from '../api/admin'
interface PolicyForm {
  title: string
  tag: string
  detail: string
  sort: string
}
const form = reactive<PolicyForm>({
  title: '攀枝花市东区人才政策申报指南',
  tag: '申报指南',
  detail: '',
  sort: '',
})
const toolbarItems = ['H', 'B', 'I', 'U', 'S', '笔', '刷', '链', '列', '齐', '引', '表', '图', '视']
function updateDetail(event: Event) {
  form.detail = (event.target as HTMLElement).innerText
}
function savePolicy() {
  ElMessage.success('政策内容已保存')
}
const route = useRoute(); const router = useRouter(); const loading = ref(false)
const form = reactive({ title: '', subtitle: '', author: '', source: '', content: '', coverUrl: '', isTop: false, sortNo: 0, status: 1 })
function formatTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 19) : '-' }
const createdAt = ref('-'); const updatedAt = ref('-')
async function load() { loading.value = true; try { const item = await getAdminPolicyDetail(String(route.params.id)); form.title = item.title; form.subtitle = item.subtitle || ''; form.author = item.author || ''; form.source = item.source || ''; form.content = item.content || ''; form.coverUrl = item.coverUrl || ''; form.isTop = Boolean(item.isTop); form.sortNo = Number(item.sortNo || 0); form.status = Number(item.status ?? 0); createdAt.value = formatTime(item.createdAt); updatedAt.value = formatTime(item.updatedAt) } finally { loading.value = false } }
async function save() { if (!form.title.trim()) { ElMessage.warning('请填写政策标题'); return }; await updateAdminPolicy(String(route.params.id), { ...form, title: form.title.trim(), subtitle: form.subtitle.trim(), author: form.author.trim(), source: form.source.trim(), coverUrl: form.coverUrl.trim() }); ElMessage.success('政策已保存'); router.push('/talent-policy-management') }
onMounted(() => void load())
</script>
<template>
  <div class="policy-edit-page">
    <section class="news-meta-panel">
      <h3>发布信息</h3>
      <div class="news-meta-grid">
        <div>
          <span>发布人</span>
          <strong>赵四</strong>
        </div>
        <div>
          <span>发布时间</span>
          <strong>2026-06-30 18:05:19</strong>
        </div>
        <div>
          <span>修改人</span>
          <strong>赵四</strong>
        </div>
        <div>
          <span>修改时间</span>
          <strong>2026-06-30 18:05:19</strong>
        </div>
      </div>
    </section>
    <section class="policy-publish-panel">
      <h3>政策内容</h3>
      <el-form class="policy-publish-form" :model="form" label-width="72px" label-position="left">
        <el-form-item label="政策标题">
          <el-input v-model="form.title" placeholder="政策标题" />
        </el-form-item>
        <el-form-item label="政策标签">
          <el-input v-model="form.tag" placeholder="政策标签" />
        </el-form-item>
        <el-form-item class="news-editor-form-item" label="政策详情">
          <div class="news-editor-shell">
            <div class="news-editor-toolbar">
              <button
                v-for="item in toolbarItems"
                :key="item"
                class="news-editor-tool"
                type="button"
                :title="item"
              >
                <el-icon v-if="item === '链'"><Link /></el-icon>
                <el-icon v-else-if="item === '图'"><Picture /></el-icon>
                <span v-else>{{ item }}</span>
              </button>
              <button class="news-editor-tool" type="button" title="后退">
                <el-icon><ArrowLeftBold /></el-icon>
              </button>
              <button class="news-editor-tool" type="button" title="前进">
                <el-icon><ArrowRightBold /></el-icon>
              </button>
            </div>
            <div class="news-editor-body" contenteditable="true" @input="updateDetail" />
          </div>
        </el-form-item>
        <el-form-item label="排序">
          <el-input v-model="form.sort" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="savePolicy">保存</el-button>
        </el-form-item>
      </el-form>
    </section>
  </div>
  <div class="policy-edit-page" v-loading="loading"><section class="news-meta-panel"><h3>发布信息</h3><div class="news-meta-grid"><div><span>发布时间</span><strong>{{ createdAt }}</strong></div><div><span>修改时间</span><strong>{{ updatedAt }}</strong></div></div></section><section class="policy-publish-panel"><h3>政策内容</h3><el-form class="policy-publish-form" :model="form" label-width="88px">
    <el-form-item label="政策标题"><el-input v-model="form.title" /></el-form-item><el-form-item label="政策摘要"><el-input v-model="form.subtitle" /></el-form-item><el-form-item label="发布人"><el-input v-model="form.author" /></el-form-item><el-form-item label="发布来源"><el-input v-model="form.source" /></el-form-item><el-form-item label="政策详情"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item><el-form-item label="封面图 URL"><el-input v-model="form.coverUrl" /></el-form-item><el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item><el-form-item label="排序值"><el-input-number v-model="form.sortNo" :min="0" :max="9999" /></el-form-item><el-form-item label="发布状态"><el-radio-group v-model="form.status"><el-radio :value="0">草稿</el-radio><el-radio :value="1">已发布</el-radio><el-radio :value="2">已下架</el-radio></el-radio-group></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  </el-form></section></div>
</template>
talent-card-admin/src/views/TalentPolicyPublishView.vue
@@ -1,80 +1,29 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeftBold, ArrowRightBold, Link, Picture } from '@element-plus/icons-vue'
import { createAdminPolicy } from '../api/admin'
interface PolicyForm {
  title: string
  tag: string
  detail: string
  sort: string
}
const form = reactive<PolicyForm>({
  title: '',
  tag: '',
  detail: '',
  sort: '',
})
const toolbarItems = ['H', 'B', 'I', 'U', 'S', '笔', '刷', '链', '列', '齐', '引', '表', '图', '视']
function updateDetail(event: Event) {
  form.detail = (event.target as HTMLElement).innerText
}
function publishPolicy() {
  ElMessage.success('政策内容已发布')
const router = useRouter()
const form = reactive({ title: '', subtitle: '', author: '', source: '', content: '', coverUrl: '', isTop: false, sortNo: 0, status: 1 })
async function save() {
  if (!form.title.trim()) { ElMessage.warning('请填写政策标题'); return }
  await createAdminPolicy({ ...form, title: form.title.trim(), subtitle: form.subtitle.trim(), author: form.author.trim(), source: form.source.trim(), coverUrl: form.coverUrl.trim() })
  ElMessage.success(form.status === 1 ? '政策已发布' : '草稿已保存')
  router.push('/talent-policy-management')
}
</script>
<template>
  <div class="policy-publish-page">
    <section class="policy-publish-panel">
      <h3>政策内容</h3>
      <el-form class="policy-publish-form" :model="form" label-width="72px" label-position="left">
        <el-form-item label="政策标题">
          <el-input v-model="form.title" placeholder="政策标题" />
        </el-form-item>
        <el-form-item label="政策标签">
          <el-input v-model="form.tag" placeholder="政策标签" />
        </el-form-item>
        <el-form-item class="news-editor-form-item" label="政策详情">
          <div class="news-editor-shell">
            <div class="news-editor-toolbar">
              <button
                v-for="item in toolbarItems"
                :key="item"
                class="news-editor-tool"
                type="button"
                :title="item"
              >
                <el-icon v-if="item === '链'"><Link /></el-icon>
                <el-icon v-else-if="item === '图'"><Picture /></el-icon>
                <span v-else>{{ item }}</span>
              </button>
              <button class="news-editor-tool" type="button" title="后退">
                <el-icon><ArrowLeftBold /></el-icon>
              </button>
              <button class="news-editor-tool" type="button" title="前进">
                <el-icon><ArrowRightBold /></el-icon>
              </button>
            </div>
            <div class="news-editor-body" contenteditable="true" @input="updateDetail" />
          </div>
        </el-form-item>
        <el-form-item label="排序">
          <el-input v-model="form.sort" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="publishPolicy">发布</el-button>
        </el-form-item>
      </el-form>
    </section>
  </div>
  <div class="policy-publish-page"><section class="policy-publish-panel"><h3>政策内容</h3><el-form class="policy-publish-form" :model="form" label-width="88px">
    <el-form-item label="政策标题"><el-input v-model="form.title" placeholder="请输入政策标题" /></el-form-item>
    <el-form-item label="政策摘要"><el-input v-model="form.subtitle" placeholder="用于小程序列表展示" /></el-form-item>
    <el-form-item label="发布人"><el-input v-model="form.author" /></el-form-item>
    <el-form-item label="发布来源"><el-input v-model="form.source" /></el-form-item>
    <el-form-item label="政策详情"><el-input v-model="form.content" type="textarea" :rows="10" /></el-form-item>
    <el-form-item label="封面图 URL"><el-input v-model="form.coverUrl" placeholder="https://..." /></el-form-item>
    <el-form-item label="是否置顶"><el-switch v-model="form.isTop" /></el-form-item>
    <el-form-item label="排序值"><el-input-number v-model="form.sortNo" :min="0" :max="9999" /></el-form-item>
    <el-form-item label="发布状态"><el-radio-group v-model="form.status"><el-radio :value="1">立即发布</el-radio><el-radio :value="0">保存草稿</el-radio></el-radio-group></el-form-item>
    <el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item>
  </el-form></section></div>
</template>
talent-card-server/docs/huifu-payment-integration.md
@@ -103,3 +103,22 @@
- Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested.
- Merchant onboarding, bank-card verification, merchant settlement/withdrawal callbacks, platform bill download, and accounting reconciliation still require the exact Huifu product contracts and callback specifications for the merchant account.
## Merchant withdrawal (encashment)
Merchant withdrawal uses Huifu `POST /v2/trade/settlement/encashment`, not the mini-program payment product. The implementation creates a withdrawal only after an administrator has recorded both values returned by Huifu provisioning:
- the approved merchant's `huifu_id` on the onboarding application;
- the approved withdrawal card's `token_no` from Huifu card binding.
In the admin settlement page, approving onboarding asks for `huifu_id`; approving a bank card asks for `token_no`. Approving a withdrawal now means **submit to Huifu**, not mark paid locally. The server stores the idempotent withdrawal number as `req_seq_id`, records the provider response, and uses `processing` until the provider confirms success.
Configure these deployment secrets before enabling withdrawal:
```text
HUIFU_WITHDRAWAL_PRODUCT_ID=...          # Product that has encashment permission, may differ from payment product
HUIFU_WITHDRAWAL_NOTIFY_URL=https://api.example.com/api/payments/huifu/withdrawal-notify
HUIFU_WITHDRAWAL_INTO_ACCOUNT_DATE_TYPE=D0
```
`HUIFU_WITHDRAWAL_NOTIFY_URL` must be publicly reachable HTTPS and configured with Huifu. The callback verifies `resp_data` with Huifu's public key before updating a withdrawal. The only terminal success is `status=paid`; `processing` never means funds arrived.
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessController.java
@@ -73,6 +73,40 @@
        return ApiResponse.success(adminBusinessService.updateNewsStatus(id, request.status()));
    }
    @GetMapping("/policies")
    public ApiResponse<PageResult<Map<String, Object>>> policies(
            @RequestParam(required = false) String title,
            @RequestParam(required = false) String author,
            @RequestParam(required = false) Integer status,
            @RequestParam(required = false) String publishStart,
            @RequestParam(required = false) String publishEnd,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size
    ) {
        return ApiResponse.success(adminBusinessService.policies(title, author, status, publishStart, publishEnd, page, size));
    }
    @GetMapping("/policies/{id}")
    public ApiResponse<Map<String, Object>> policyDetail(@PathVariable Long id) {
        return ApiResponse.success(adminBusinessService.policyDetail(id));
    }
    @PostMapping("/policies")
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<Map<String, Object>> createPolicy(@Valid @RequestBody PolicyArticleRequest request) {
        return ApiResponse.success(adminBusinessService.createPolicy(request));
    }
    @PutMapping("/policies/{id}")
    public ApiResponse<Map<String, Object>> updatePolicy(@PathVariable Long id, @Valid @RequestBody PolicyArticleRequest request) {
        return ApiResponse.success(adminBusinessService.updatePolicy(id, request));
    }
    @PatchMapping("/policies/{id}/status")
    public ApiResponse<Map<String, Object>> updatePolicyStatus(@PathVariable Long id, @Valid @RequestBody StatusUpdateRequest request) {
        return ApiResponse.success(adminBusinessService.updatePolicyStatus(id, request.status()));
    }
    @GetMapping("/merchants")
    public ApiResponse<PageResult<Map<String, Object>>> merchants(
            @RequestParam(required = false) String keyword,
@@ -102,6 +136,11 @@
        return ApiResponse.success(adminBusinessService.merchant(id));
    }
    @GetMapping("/merchants/login-account-available")
    public ApiResponse<Map<String, Object>> merchantLoginAccountAvailable(@RequestParam String account) {
        return ApiResponse.success(adminBusinessService.merchantLoginAccountAvailable(account));
    }
    @PostMapping("/merchants")
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<Map<String, Object>> createMerchant(@Valid @RequestBody MerchantCreateRequest request) {
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminBusinessService.java
@@ -10,6 +10,7 @@
import com.talentcard.server.business.BusinessIdGenerator;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.PageResult;
import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.jsoup.Jsoup;
@@ -155,6 +156,52 @@
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> policies(String title, String author, Integer status, String publishStart, String publishEnd, int page, int size) {
        StringBuilder sql = new StringBuilder("select id, policy_no, title, subtitle, cover_url, author, source, view_count, is_top, sort_no, publish_time, status, created_at, updated_at from policy_article where deleted = 0");
        StringBuilder countSql = new StringBuilder("select count(*) from policy_article where deleted = 0");
        List<Object> args = new ArrayList<>();
        if (title != null && !title.isBlank()) { sql.append(" and title like ?"); countSql.append(" and title like ?"); args.add("%" + title.trim() + "%"); }
        if (author != null && !author.isBlank()) {
            sql.append(" and (author like ? or source like ?)"); countSql.append(" and (author like ? or source like ?)");
            String like = "%" + author.trim() + "%"; args.add(like); args.add(like);
        }
        if (status != null) { sql.append(" and status = ?"); countSql.append(" and status = ?"); args.add(status); }
        if (publishStart != null && !publishStart.isBlank()) { sql.append(" and date(publish_time) >= ?"); countSql.append(" and date(publish_time) >= ?"); args.add(publishStart); }
        if (publishEnd != null && !publishEnd.isBlank()) { sql.append(" and date(publish_time) <= ?"); countSql.append(" and date(publish_time) <= ?"); args.add(publishEnd); }
        sql.append(" order by is_top desc, sort_no desc, publish_time desc, id desc");
        return page(sql.toString(), countSql.toString(), page, size, args.toArray());
    }
    @Transactional(readOnly = true)
    public Map<String, Object> policyDetail(Long id) {
        return one("select id, policy_no, title, subtitle, cover_url, author, source, content, view_count, is_top, sort_no, publish_time, status, created_at, updated_at from policy_article where deleted = 0 and id = ?", id);
    }
    @Transactional
    public Map<String, Object> createPolicy(PolicyArticleRequest request) {
        int status = defaultNewsStatus(request.status());
        jdbcTemplate.update("insert into policy_article(policy_no, title, subtitle, cover_url, author, source, content, is_top, sort_no, publish_time, status) values (?, ?, ?, ?, ?, ?, ?, ?, ?, case when ? = 1 then now() else null end, ?)",
                BusinessIdGenerator.next("POLICY"), request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status);
        return policyDetail(jdbcTemplate.queryForObject("select last_insert_id()", Long.class));
    }
    @Transactional
    public Map<String, Object> updatePolicy(Long id, PolicyArticleRequest request) {
        int status = defaultNewsStatus(request.status());
        int updated = jdbcTemplate.update("update policy_article set title = ?, subtitle = ?, cover_url = ?, author = ?, source = ?, content = ?, is_top = ?, sort_no = ?, status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?",
                request.title().trim(), blankToNull(request.subtitle()), blankToNull(request.coverUrl()), blankToNull(request.author()), blankToNull(request.source()), sanitizeNewsContent(request.content()), Boolean.TRUE.equals(request.isTop()) ? 1 : 0, request.sortNo() == null ? 0 : request.sortNo(), status, status, id);
        ensureUpdated(updated);
        return policyDetail(id);
    }
    @Transactional
    public Map<String, Object> updatePolicyStatus(Long id, Byte status) {
        int updated = jdbcTemplate.update("update policy_article set status = ?, publish_time = case when ? = 1 then coalesce(publish_time, now()) else publish_time end where deleted = 0 and id = ?", status, status, id);
        ensureUpdated(updated);
        return policyDetail(id);
    }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> merchants(
            String keyword,
            String contactMobile,
@@ -169,7 +216,21 @@
        StringBuilder sql = new StringBuilder("""
                select m.id, m.merchant_name, m.merchant_type, m.contact_name, m.contact_mobile,
                       m.logo_url, m.cover_url, m.address, m.is_recommend, m.sort_no, m.status,
                       m.business_status, m.created_at,
                       case
                         when m.status <> 1 then '休息中'
                         when lower(coalesce(m.business_status, 'open')) in ('closed', '休息中') then '休息中'
                         when exists (
                           select 1
                           from merchant_business_hours h
                           join merchant_store s on s.id = h.store_id and s.status = 1 and s.deleted = 0
                           where h.merchant_id = m.id
                             and (
                               (h.start_time <= h.end_time and current_time() >= h.start_time and current_time() < h.end_time)
                               or (h.start_time > h.end_time and (current_time() >= h.start_time or current_time() < h.end_time))
                             )
                         ) then '营业中'
                         else '休息中'
                       end as business_status, m.created_at,
                       (select count(*) from merchant_product p where p.merchant_id = m.id and p.deleted = 0) as product_count,
                       (select count(*) from benefit_order o where o.merchant_id = m.id and o.deleted = 0) as order_count,
                       0 as reservation_count
@@ -224,20 +285,34 @@
    public Map<String, Object> merchant(Long id) {
        return one("""
                select id, merchant_name, merchant_type, contact_name, contact_mobile, logo_url, cover_url,
                       address, business_status, longitude, latitude, group_buying_enabled, usage_instructions,
                       rights_text, service_label, is_recommend, sort_no, status, created_at, updated_at
                       address, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled, usage_instructions,
                       rights_text, merchant_rights_config, service_label, is_recommend, sort_no, status, created_at, updated_at,
                       (select o.account from merchant_operator o
                        where o.merchant_id = merchant.id and o.role_code = 'owner' and o.deleted = 0
                        order by o.id asc limit 1) as login_account
                from merchant
                where deleted = 0 and id = ?
                """, id);
    }
    @Transactional(readOnly = true)
    public Map<String, Object> merchantLoginAccountAvailable(String account) {
        String normalizedAccount = account == null ? "" : account.trim();
        if (!normalizedAccount.matches("^[A-Za-z0-9]{1,32}$")) {
            return Map.of("available", false);
        }
        Long count = jdbcTemplate.queryForObject(
                "select count(*) from merchant_operator where account = ?", Long.class, normalizedAccount);
        return Map.of("available", count == null || count == 0);
    }
    @Transactional
    public Map<String, Object> createMerchant(MerchantCreateRequest request) {
        jdbcTemplate.update("""
                insert into merchant(merchant_name, merchant_type, contact_name, contact_mobile, address,
                                     logo_url, cover_url, business_status, longitude, latitude, group_buying_enabled,
                                     usage_instructions, rights_text, service_label, is_recommend, status, sort_no)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)
                                         logo_url, cover_url, business_status, longitude, latitude, can_login_merchant_app, group_buying_enabled,
                                         usage_instructions, rights_text, merchant_rights_config, service_label, is_recommend, status, sort_no)
                values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0)
                """,
                request.merchantName(),
                request.merchantType(),
@@ -249,13 +324,36 @@
                request.businessStatus(),
                request.longitude(),
                request.latitude(),
                request.canLoginMerchantApp() == null ? 1 : request.canLoginMerchantApp(),
                request.groupBuyingEnabled() == null ? 1 : request.groupBuyingEnabled(),
                request.usageInstructions(),
                request.rightsText(),
                toJson(request.merchantRightsConfig()),
                request.serviceLabel(),
                request.recommend() == null ? 0 : request.recommend());
        Long id = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
        if (isFoodMerchantWithAppAccess(request)) {
            String account = request.loginAccount().trim();
            if (!account.matches("^[A-Za-z0-9]{1,32}$")) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录账号只能包含英文字母和数字");
            }
            if (request.loginPassword() == null || request.loginPassword().length() < 6 || request.loginPassword().length() > 64) {
                throw new BusinessException(ErrorCode.BAD_REQUEST, "商户登录密码长度须为6至64位");
            }
            Integer occupied = jdbcTemplate.queryForObject("select count(*) from merchant_operator where account = ?", Integer.class, account);
            if (occupied != null && occupied > 0) throw new BusinessException(ErrorCode.CONFLICT, "商户登录账号已存在");
            jdbcTemplate.update("insert into merchant_store(merchant_id, store_name, address, status) values (?, ?, ?, 1)", id, request.merchantName().trim(), request.address());
            Long storeId = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
            jdbcTemplate.update("insert into merchant_operator(merchant_id, account, password_hash, display_name, role_code, status) values (?, ?, ?, ?, 'owner', 1)",
                    id, account, MerchantPasswordHasher.encode(request.loginPassword()), request.merchantName().trim());
            Long operatorId = jdbcTemplate.queryForObject("select last_insert_id()", Long.class);
            jdbcTemplate.update("insert into merchant_operator_store(operator_id, store_id) values (?, ?)", operatorId, storeId);
        }
        return one("select * from merchant where id = ?", id);
    }
    private boolean isFoodMerchantWithAppAccess(MerchantCreateRequest request) {
        return "餐饮服务".equals(request.merchantType()) && (request.canLoginMerchantApp() == null || request.canLoginMerchantApp() == 1);
    }
    @Transactional
@@ -263,8 +361,8 @@
        int updated = jdbcTemplate.update("""
                update merchant
                set merchant_name = ?, merchant_type = ?, contact_name = ?, contact_mobile = ?,
                    address = ?, logo_url = ?, cover_url = ?, business_status = ?, longitude = ?, latitude = ?,
                    group_buying_enabled = ?, usage_instructions = ?, rights_text = ?, service_label = ?,
                    address = ?, logo_url = ?, cover_url = ?, business_status = ?, longitude = ?, latitude = ?, can_login_merchant_app = ?,
                    group_buying_enabled = ?, usage_instructions = ?, rights_text = ?, merchant_rights_config = ?, service_label = ?,
                    is_recommend = ?, sort_no = ?, status = ?
                where deleted = 0 and id = ?
                """,
@@ -278,9 +376,11 @@
                request.businessStatus(),
                request.longitude(),
                request.latitude(),
                request.canLoginMerchantApp() == null ? 1 : request.canLoginMerchantApp(),
                request.groupBuyingEnabled() == null ? 1 : request.groupBuyingEnabled(),
                request.usageInstructions(),
                request.rightsText(),
                toJson(request.merchantRightsConfig()),
                request.serviceLabel(),
                request.recommend() == null ? 0 : request.recommend(),
                request.sortNo() == null ? 0 : request.sortNo(),
@@ -367,10 +467,9 @@
            args.add("%" + contactMobile.trim() + "%");
        }
        if (keyword != null && !keyword.isBlank()) {
            sql.append(" and (p.product_name like ? or p.product_no like ?)");
            countSql.append(" and (p.product_name like ? or p.product_no like ?)");
            sql.append(" and p.product_name like ?");
            countSql.append(" and p.product_name like ?");
            String like = "%" + keyword.trim() + "%";
            args.add(like);
            args.add(like);
        }
        if (type != null && !type.isBlank()) {
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -21,11 +21,13 @@
  @GetMapping("/staff") public ApiResponse<List<Map<String,Object>>> staff() { return ApiResponse.success(service.staff()); }
  @PostMapping("/staff") public ApiResponse<Map<String,Object>> createStaff(@Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.createStaff(request)); }
  @PutMapping("/staff/{id}") public ApiResponse<Map<String,Object>> updateStaff(@PathVariable Long id, @Valid @RequestBody AdminStaffRequest request) { return ApiResponse.success(service.updateStaff(id, request)); }
  @PatchMapping("/staff/{id}/password") public ApiResponse<Map<String,Object>> resetStaffPassword(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(service.resetStaffPassword(id, body.get("password"))); }
  @DeleteMapping("/staff/{id}") public ApiResponse<Map<String,Object>> deleteStaff(@PathVariable Long id) { return ApiResponse.success(service.deleteStaff(id)); }
  @GetMapping("/merchant-settlement/onboardings") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> onboardings(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.onboardings(page, size)); }
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.bankCards(page, size)); }
  @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(settlementService.withdrawals(page, size)); }
  @PatchMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> reviewOnboarding(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewOnboarding(id, body.get("status"), body.get("reason"))); }
  @PatchMapping("/merchant-settlement/bank-cards/{id}") public ApiResponse<Map<String,Object>> reviewBankCard(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewBankCard(id, body.get("status"), body.get("reason"))); }
  @PatchMapping("/merchant-settlement/onboardings/{id}") public ApiResponse<Map<String,Object>> reviewOnboarding(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewOnboarding(id, body.get("status"), body.get("reason"), body.get("huifuId"))); }
  @PatchMapping("/merchant-settlement/bank-cards/{id}") public ApiResponse<Map<String,Object>> reviewBankCard(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewBankCard(id, body.get("status"), body.get("reason"), body.get("tokenNo"))); }
  @PatchMapping("/merchant-settlement/withdrawals/{id}") public ApiResponse<Map<String,Object>> reviewWithdrawal(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(settlementService.reviewWithdrawal(id, body.get("status"), body.get("reason"))); }
  @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); }
  @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); }
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementService.java
@@ -1,21 +1,42 @@
package com.talentcard.server.business.admin;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.auth.MerchantPasswordHasher;
import com.talentcard.server.common.*;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service public class AdminManagementService {
  private final JdbcTemplate jdbc; public AdminManagementService(JdbcTemplate jdbc) { this.jdbc=jdbc; }
  public Map<String,Object> settings() { Map<String,Object> result=new LinkedHashMap<>(); for(Map<String,Object> row: rows("select setting_key, setting_value from admin_setting")) result.put(String.valueOf(row.get("settingKey")), row.get("settingValue")); return result; }
  @Transactional public Map<String,Object> saveSettings(Map<String,String> body) { for(String key: List.of("bookstoreDiscount","hotelSubsidy","homeVideoUrl","miniProgramUrl")) jdbc.update("insert into admin_setting(setting_key, setting_value) values (?, ?) on duplicate key update setting_value = values(setting_value)",key,body.getOrDefault(key,"")); return settings(); }
  public List<Map<String,Object>> roles() { return rows("select id, role_name, role_code, remark, status, created_at from admin_role order by id"); }
  @Transactional public Map<String,Object> createRole(AdminRoleRequest r) { jdbc.update("insert into admin_role(role_name,role_code,remark,status) values (?,?,?,?)",r.roleName().trim(),r.roleCode().trim(),r.remark(),r.status()); return one("select id, role_name, role_code, remark, status, created_at from admin_role where id=last_insert_id()"); }
  @Transactional public Map<String,Object> updateRole(Long id, AdminRoleRequest r) { if(jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,status=? where id=?",r.roleName().trim(),r.roleCode().trim(),r.remark(),r.status(),id)==0) throw new BusinessException(ErrorCode.NOT_FOUND,"角色不存在"); return one("select id, role_name, role_code, remark, status, created_at from admin_role where id=?",id); }
  public List<Map<String,Object>> staff() { return rows("select id,account,display_name,role_code,status,created_at from admin_operator where deleted=0 order by id"); }
  @Transactional public Map<String,Object> createStaff(AdminStaffRequest r) { if(r.password()==null||r.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST,"新建员工必须设置登录密码"); jdbc.update("insert into admin_operator(account,password_hash,display_name,role_code,status) values (?,?,?,?,?)",r.account().trim(),MerchantPasswordHasher.encode(r.password()),r.displayName().trim(),r.roleCode(),r.status()); return one("select id,account,display_name,role_code,status,created_at from admin_operator where id=last_insert_id()"); }
  @Transactional public Map<String,Object> updateStaff(Long id, AdminStaffRequest r) { String sql=(r.password()==null||r.password().isBlank())?"update admin_operator set account=?,display_name=?,role_code=?,status=? where id=?":"update admin_operator set account=?,password_hash=?,display_name=?,role_code=?,status=? where id=?"; int n=(r.password()==null||r.password().isBlank())?jdbc.update(sql,r.account().trim(),r.displayName().trim(),r.roleCode(),r.status(),id):jdbc.update(sql,r.account().trim(),MerchantPasswordHasher.encode(r.password()),r.displayName().trim(),r.roleCode(),r.status(),id); if(n==0) throw new BusinessException(ErrorCode.NOT_FOUND,"员工不存在"); return one("select id,account,display_name,role_code,status,created_at from admin_operator where id=?",id); }
  private List<Map<String,Object>> rows(String sql,Object...args){return DatabaseRows.normalize(jdbc.queryForList(sql,args));} private Map<String,Object> one(String sql,Object...args){List<Map<String,Object>> r=rows(sql,args);if(r.isEmpty())throw new BusinessException(ErrorCode.NOT_FOUND,"记录不存在");return r.get(0);}
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@Service
public class AdminManagementService {
    private final JdbcTemplate jdbc;
    public AdminManagementService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    public Map<String, Object> settings() { Map<String, Object> result = new LinkedHashMap<>(); for (Map<String, Object> row : rows("select setting_key, setting_value from admin_setting")) result.put(String.valueOf(row.get("settingKey")), row.get("settingValue")); return result; }
    @Transactional public Map<String, Object> saveSettings(Map<String, String> body) { for (String key : List.of("bookstoreDiscount", "hotelSubsidy", "homeVideoUrl", "miniProgramUrl")) jdbc.update("insert into admin_setting(setting_key, setting_value) values (?, ?) on duplicate key update setting_value = values(setting_value)", key, body.getOrDefault(key, "")); return settings(); }
    public List<Map<String, Object>> roles() { return rows("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role order by id"); }
    @Transactional public Map<String, Object> createRole(AdminRoleRequest request) { String code = roleCode(request.roleCode()); jdbc.update("insert into admin_role(role_name,role_code,remark,permission_codes,status) values (?,?,?,?,?)", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status()); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=last_insert_id()"); }
    @Transactional public Map<String, Object> updateRole(Long id, AdminRoleRequest request) { String code = request.roleCode() == null || request.roleCode().isBlank() ? existingRoleCode(id) : request.roleCode().trim(); if (jdbc.update("update admin_role set role_name=?,role_code=?,remark=?,permission_codes=?,status=? where id=?", request.roleName().trim(), code, request.remark(), permissions(request.permissionCodes()), request.status(), id) == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return one("select id, role_name, role_code, remark, permission_codes, status, created_at from admin_role where id=?", id); }
    public List<Map<String, Object>> staff() { return rows("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where deleted=0 order by id"); }
    @Transactional public Map<String, Object> createStaff(AdminStaffRequest request) { if (request.password() == null || request.password().isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "A password is required for a new staff account"); jdbc.update("insert into admin_operator(account,password_hash,display_name,mobile,role_code,position_name,customer_wechat,status) values (?,?,?,?,?,?,?,?)", request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status()); return staffOne("last_insert_id()"); }
    @Transactional public Map<String, Object> updateStaff(Long id, AdminStaffRequest request) { String sql = request.password() == null || request.password().isBlank() ? "update admin_operator set account=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?" : "update admin_operator set account=?,password_hash=?,display_name=?,mobile=?,role_code=?,position_name=?,customer_wechat=?,status=? where id=?"; int updated = request.password() == null || request.password().isBlank() ? jdbc.update(sql, request.account().trim(), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id) : jdbc.update(sql, request.account().trim(), MerchantPasswordHasher.encode(request.password()), request.displayName().trim(), blankToNull(request.mobile()), request.roleCode(), request.positionName().trim(), blankToNull(request.customerWechat()), request.status(), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return staffOne("?", id); }
    @Transactional public Map<String, Object> resetStaffPassword(Long id, String password) { if (password == null || password.length() < 6 || password.length() > 64) throw new BusinessException(ErrorCode.BAD_REQUEST, "Password must be 6-64 characters"); int updated = jdbc.update("update admin_operator set password_hash=?, login_failed_count=0, locked_until=null where id=? and deleted=0", MerchantPasswordHasher.encode(password), id); if (updated == 0) throw new BusinessException(ErrorCode.NOT_FOUND, "Staff account does not exist"); return Map.of("id", id, "reset", true); }
    @Transactional public Map<String, Object> deleteStaff(Long id) { int updated = jdbc.update("update admin_operator set deleted=1, status=0 where id=? and deleted=0 and account <> 'admin'", id); if (updated == 0) throw new BusinessException(ErrorCode.CONFLICT, "The account cannot be deleted or does not exist"); return Map.of("id", id, "deleted", true); }
    private String roleCode(String supplied) { return supplied == null || supplied.isBlank() ? "role_" + System.currentTimeMillis() : supplied.trim(); }
    private Map<String, Object> staffOne(String where, Object... args) { return one("select id,account,display_name,mobile,role_code,position_name,customer_wechat,status,created_at from admin_operator where id=" + where, args); }
    private String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
    private String existingRoleCode(Long id) { List<Map<String, Object>> result = rows("select role_code from admin_role where id=?", id); if (result.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Role does not exist"); return String.valueOf(result.get(0).get("roleCode")); }
    private String permissions(List<String> values) { LinkedHashSet<String> clean = new LinkedHashSet<>(); if (values != null) for (String value : values) if (value != null && !value.isBlank()) clean.add(value.trim()); return JSON.toJSONString(clean); }
    private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); }
    private Map<String, Object> one(String sql, Object... args) { List<Map<String, Object>> result = rows(sql, args); if (result.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "Record does not exist"); return result.get(0); }
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminRoleRequest.java
@@ -1,3 +1,9 @@
package com.talentcard.server.business.admin;
import jakarta.validation.constraints.*;
public record AdminRoleRequest(@NotBlank @Size(max=64) String roleName, @NotBlank @Pattern(regexp="^[A-Za-z0-9_]{2,32}$") String roleCode, @Size(max=255) String remark, @NotNull Byte status) {}
public record AdminRoleRequest(
        @NotBlank @Size(max=64) String roleName,
        @Pattern(regexp="^$|^[A-Za-z0-9_]{2,32}$") String roleCode,
        @Size(max=255) String remark,
        @NotNull Byte status,
        java.util.List<@Pattern(regexp="^[a-z0-9_.-]{1,80}$") String> permissionCodes
) {}
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminStaffRequest.java
@@ -1,3 +1,12 @@
package com.talentcard.server.business.admin;
import jakarta.validation.constraints.*;
public record AdminStaffRequest(@NotBlank @Size(max=32) String account, @Size(max=64) String password, @NotBlank @Size(max=64) String displayName, @NotBlank @Size(max=32) String roleCode, @NotNull Byte status) {}
public record AdminStaffRequest(
        @NotBlank @Size(max=32) String account,
        @Size(max=64) String password,
        @NotBlank @Size(max=64) String displayName,
        @Pattern(regexp="^$|^1\\d{10}$") String mobile,
        @NotBlank @Size(max=32) String roleCode,
        @NotBlank @Size(max=64) String positionName,
        @Size(max=64) String customerWechat,
        @NotNull Byte status
) {}
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantCreateRequest.java
@@ -3,6 +3,9 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.Map;
public record MerchantCreateRequest(
        @NotBlank
        @Size(max = 128)
@@ -25,7 +28,11 @@
        Byte groupBuyingEnabled,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,
        @Size(max = 64)
        String serviceLabel
        String serviceLabel,
        Byte canLoginMerchantApp,
        @Size(max = 32) String loginAccount,
        @Size(max = 64) String loginPassword
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -11,11 +11,13 @@
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.talentcard.server.business.merchant.HuifuMerchantWithdrawalService;
@Service
public class MerchantSettlementAdminService {
    private final JdbcTemplate jdbc;
    public MerchantSettlementAdminService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
    private final HuifuMerchantWithdrawalService withdrawalService;
    public MerchantSettlementAdminService(JdbcTemplate jdbc, HuifuMerchantWithdrawalService withdrawalService) { this.jdbc = jdbc; this.withdrawalService = withdrawalService; }
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> onboardings(int page, int size) { return page("select a.*, m.merchant_name, s.store_name from merchant_onboarding_application a join merchant m on m.id = a.merchant_id join merchant_store s on s.id = a.store_id", "select count(*) from merchant_onboarding_application", page, size); }
@@ -24,9 +26,14 @@
    @Transactional(readOnly = true)
    public PageResult<Map<String, Object>> withdrawals(int page, int size) { return page("select w.*, c.bank_name, c.card_masked, m.merchant_name, s.store_name from merchant_withdrawal w join merchant_bank_card c on c.id = w.bank_card_id join merchant m on m.id = w.merchant_id join merchant_store s on s.id = w.store_id", "select count(*) from merchant_withdrawal", page, size); }
    @Transactional public Map<String, Object> reviewOnboarding(Long id, String status, String reason) { return update("merchant_onboarding_application", id, status, reason, "onboarding"); }
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason) { return update("merchant_bank_card", id, status, reason, "bank card"); }
    @Transactional public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { return update("merchant_withdrawal", id, status, reason, "withdrawal"); }
    @Transactional public Map<String, Object> reviewOnboarding(Long id, String status, String reason, String huifuId) { return reviewOnboardingInternal(id, status, reason, huifuId); }
    @Transactional public Map<String, Object> reviewBankCard(Long id, String status, String reason, String tokenNo) { return reviewBankCardInternal(id, status, reason, tokenNo); }
    public Map<String, Object> reviewWithdrawal(Long id, String status, String reason) { if ("paid".equals(normalizeWithdrawalStatus(status))) return withdrawalService.dispatch(id); return update("merchant_withdrawal", id, "failed", reason, "withdrawal"); }
    private Map<String,Object> reviewOnboardingInternal(Long id,String status,String reason,String huifuId) { String value=reviewStatus(status); if("approved".equals(value) && (huifuId==null || !huifuId.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu merchant ID is required when approving onboarding"); int affected=jdbc.update("update merchant_onboarding_application set status=?, reject_reason=?, provider_huifu_id=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?huifuId.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Onboarding is already handled or does not exist");return Map.of("id",id,"status",value); }
    private Map<String,Object> reviewBankCardInternal(Long id,String status,String reason,String tokenNo) { String value=reviewStatus(status); if("approved".equals(value) && (tokenNo==null || !tokenNo.trim().matches("[A-Za-z0-9]{1,32}"))) throw new BusinessException(ErrorCode.BAD_REQUEST,"Huifu withdrawal card token is required when approving a bank card"); int affected=jdbc.update("update merchant_bank_card set status=?, reject_reason=?, provider_token_no=?, reviewed_at=now() where id=? and status='pending'",value,reason==null?"":reason.trim(),"approved".equals(value)?tokenNo.trim():null,id); if(affected==0)throw new BusinessException(ErrorCode.CONFLICT,"Bank card is already handled or does not exist");return Map.of("id",id,"status",value); }
    private String reviewStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("approved","rejected").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid review status");return value; }
    private String normalizeWithdrawalStatus(String status) { String value=status==null?"":status.trim().toLowerCase(); if(!List.of("paid","failed").contains(value))throw new BusinessException(ErrorCode.BAD_REQUEST,"Invalid withdrawal status");return value; }
    private PageResult<Map<String, Object>> page(String query, String count, int page, int size) { int current = Math.max(1, page); int limit = Math.min(Math.max(1, size), 100); List<Map<String, Object>> values = rows(query + " order by id desc limit ? offset ?", limit, (current - 1) * limit); Long total = jdbc.queryForObject(count, Long.class); return new PageResult<>(values, current, limit, total == null ? 0 : total); }
    private Map<String, Object> update(String table, Long id, String status, String reason, String label) {
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantUpdateRequest.java
@@ -3,6 +3,9 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.Map;
public record MerchantUpdateRequest(
        @NotBlank
        @Size(max = 128)
@@ -27,7 +30,9 @@
        Byte groupBuyingEnabled,
        String usageInstructions,
        String rightsText,
        List<Map<String, Object>> merchantRightsConfig,
        @Size(max = 64)
        String serviceLabel
        String serviceLabel,
        Byte canLoginMerchantApp
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/PolicyArticleRequest.java
New file
@@ -0,0 +1,19 @@
package com.talentcard.server.business.admin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record PolicyArticleRequest(
        @NotBlank @Size(max = 255) String title,
        @Size(max = 255) String subtitle,
        @Size(max = 500) String coverUrl,
        @Size(max = 64) String author,
        @Size(max = 128) String source,
        String content,
        Boolean isTop,
        Integer sortNo,
        @Min(0) @Max(2) Integer status
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductCreateRequest.java
@@ -4,7 +4,9 @@
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -18,17 +20,17 @@
        @Size(max = 128)
        String productName,
        String subtitle,
        String coverUrl,
        @NotBlank String coverUrl,
        BigDecimal originalPrice,
        BigDecimal salePrice,
        @NotNull @DecimalMin(value = "0.01") BigDecimal salePrice,
        BigDecimal subsidyAmount,
        Integer stock,
        String detailText,
        @NotBlank String detailText,
        String benefitDesc,
        Map<String, Object> purchaseNotice,
        String validStartTime,
        String validEndTime,
        List<String> productImages,
        @NotEmpty List<@NotBlank String> productImages,
        List<Map<String, Object>> rightsConfig
) {
}
talent-card-server/src/main/java/com/talentcard/server/business/admin/ProductUpdateRequest.java
@@ -4,7 +4,9 @@
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -18,17 +20,17 @@
        @Size(max = 128)
        String productName,
        String subtitle,
        String coverUrl,
        @NotBlank String coverUrl,
        BigDecimal originalPrice,
        BigDecimal salePrice,
        @NotNull @DecimalMin(value = "0.01") BigDecimal salePrice,
        BigDecimal subsidyAmount,
        Integer stock,
        String detailText,
        @NotBlank String detailText,
        String benefitDesc,
        Map<String, Object> purchaseNotice,
        String validStartTime,
        String validEndTime,
        List<String> productImages,
        @NotEmpty List<@NotBlank String> productImages,
        List<Map<String, Object>> rightsConfig,
        Byte status,
        Integer sortNo
talent-card-server/src/main/java/com/talentcard/server/business/merchant/HuifuMerchantWithdrawalService.java
New file
@@ -0,0 +1,74 @@
package com.talentcard.server.business.merchant;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand;
import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
@Service
public class HuifuMerchantWithdrawalService {
    private final JdbcTemplate jdbc;
    private final HuifuWithdrawalGateway gateway;
    public HuifuMerchantWithdrawalService(JdbcTemplate jdbc, HuifuWithdrawalGateway gateway) { this.jdbc = jdbc; this.gateway = gateway; }
    public Map<String, Object> dispatch(Long withdrawalId) {
        PreparedWithdrawal prepared = prepare(withdrawalId);
        try {
            ProviderResult result = ProviderResult.from(gateway.create(new HuifuWithdrawalCommand(prepared.withdrawalNo(), prepared.amountCents(), prepared.huifuId(), prepared.tokenNo())));
            if (result.success()) complete(prepared.withdrawalNo(), result);
            else if (result.failed()) fail(prepared.withdrawalNo(), result.code(), result.message());
            else pending(prepared.withdrawalNo(), result);
        } catch (HuifuPaymentGatewayException exception) {
            // The provider may have accepted the request. Keep its idempotent request number in processing state.
            jdbc.update("update merchant_withdrawal set status='processing', provider_request_date=?, failure_reason=null where withdrawal_no=? and status='processing'", requestDate(), prepared.withdrawalNo());
        }
        return detail(prepared.withdrawalNo());
    }
    @Transactional
    public PreparedWithdrawal prepare(Long withdrawalId) {
        List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList("""
                select w.withdrawal_no, w.amount_cents, w.status, o.provider_huifu_id, c.provider_token_no
                from merchant_withdrawal w
                join merchant_onboarding_application o on o.merchant_id=w.merchant_id and o.store_id=w.store_id and o.status='approved'
                join merchant_bank_card c on c.id=w.bank_card_id and c.status='approved'
                where w.id=?
                order by o.id desc limit 1 for update
                """, withdrawalId));
        if (rows.isEmpty()) throw new BusinessException(ErrorCode.CONFLICT, "Withdrawal settlement configuration is incomplete");
        Map<String, Object> row = rows.get(0);
        if (!"pending".equals(text(row.get("status")))) throw new BusinessException(ErrorCode.CONFLICT, "Withdrawal has already been processed");
        String huifuId = text(row.get("providerHuifuId")), tokenNo = text(row.get("providerTokenNo"));
        if (huifuId.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Approved merchant Huifu ID is not configured");
        if (tokenNo.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "Approved withdrawal card token is not configured");
        int locked = jdbc.update("update merchant_withdrawal set status='processing', reviewed_at=now(), failure_reason=null, provider_request_date=? where id=? and status='pending'", requestDate(), withdrawalId);
        if (locked == 0) throw new BusinessException(ErrorCode.CONFLICT, "Withdrawal has already been processed");
        return new PreparedWithdrawal(text(row.get("withdrawalNo")), number(row.get("amountCents")), huifuId, tokenNo);
    }
    @Transactional public void handleCallback(String withdrawalNo, Map<String, Object> data) {
        ProviderResult result = ProviderResult.from(data);
        if (result.success()) complete(withdrawalNo, result); else if (result.failed()) fail(withdrawalNo, result.code(), result.message()); else pending(withdrawalNo, result);
    }
    public boolean exists(String withdrawalNo) { Integer count = jdbc.queryForObject("select count(*) from merchant_withdrawal where withdrawal_no=?", Integer.class, withdrawalNo); return count != null && count > 0; }
    private void complete(String no, ProviderResult r) { jdbc.update("update merchant_withdrawal set status='paid', provider_trade_no=coalesce(?,provider_trade_no), provider_response_code=?, provider_response_message=?, paid_at=coalesce(paid_at,now()), failure_reason=null where withdrawal_no=? and status in ('pending','processing')", r.providerNo(), r.code(), r.message(), no); }
    private void fail(String no, String code, String message) { jdbc.update("update merchant_withdrawal set status='failed', provider_response_code=?, provider_response_message=?, failure_reason=? where withdrawal_no=? and status in ('pending','processing')", code, message, message, no); }
    private void pending(String no, ProviderResult r) { jdbc.update("update merchant_withdrawal set status='processing', provider_trade_no=coalesce(?,provider_trade_no), provider_response_code=?, provider_response_message=? where withdrawal_no=? and status in ('pending','processing')", r.providerNo(), r.code(), r.message(), no); }
    private Map<String, Object> detail(String no) { return DatabaseRows.normalize(jdbc.queryForList("select withdrawal_no,status,provider_trade_no,provider_response_code,provider_response_message,failure_reason,paid_at from merchant_withdrawal where withdrawal_no=?", no)).get(0); }
    private String requestDate() { return LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); }
    private String text(Object v) { return v == null ? "" : String.valueOf(v).trim(); }
    private long number(Object v) { return v instanceof Number n ? n.longValue() : 0; }
    private record PreparedWithdrawal(String withdrawalNo, long amountCents, String huifuId, String tokenNo) {}
    @SuppressWarnings("unchecked") private record ProviderResult(boolean success, boolean failed, String code, String message, String providerNo) {
        static ProviderResult from(Map<String, Object> response) { Map<String,Object> d=response.get("data") instanceof Map<?,?> map?(Map<String,Object>)map:response; String code=value(d,"sub_resp_code","resp_code","code"), status=value(d,"trans_status","trans_stat"), message=value(d,"sub_resp_desc","resp_desc","message"), no=value(d,"hf_seq_id"); boolean accepted="00000000".equals(code)||"10000".equals(code)||"0".equals(code); boolean ok=accepted&&("S".equalsIgnoreCase(status)||"SUCCESS".equalsIgnoreCase(status)); boolean pending="P".equalsIgnoreCase(status)||"PROCESSING".equalsIgnoreCase(status)||status.isBlank(); return new ProviderResult(ok,!ok&&!pending,code,message,no); }
        static String value(Map<String,Object> data,String...keys){for(String key:keys)if(data.get(key)!=null)return String.valueOf(data.get(key)).trim();return "";}
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantManagementService.java
@@ -39,7 +39,7 @@
        result.put("storeName", store.get("storeName"));
        result.put("address", value(store.get("address"), value(merchant.get("address"), "")));
        result.put("coverUrl", value(merchant.get("coverUrl"), ""));
        result.put("businessStatus", businessStatus(merchant.get("businessStatus")));
        result.put("businessStatus", isBusinessOpen(merchant, principal.storeId()) ? "open" : "closed");
        result.put("updatedAt", merchant.get("updatedAt"));
        Long productCount = jdbc.queryForObject("select count(*) from merchant_product where merchant_id = ? and deleted = 0 and status = 1 and audit_status = 2", Long.class, principal.merchantId());
        result.put("onlineProductCount", productCount == null ? 0 : productCount);
@@ -52,7 +52,9 @@
        merchant(principal.merchantId());
        store(principal);
        jdbc.update("update merchant set business_status = ? where id = ? and deleted = 0", status.toUpperCase(), principal.merchantId());
        return Map.of("businessStatus", status, "updatedAt", java.time.LocalDateTime.now().toString());
        return Map.of(
                "businessStatus", isBusinessOpen(merchant(principal.merchantId()), principal.storeId()) ? "open" : "closed",
                "updatedAt", java.time.LocalDateTime.now().toString());
    }
    @Transactional(readOnly = true)
@@ -167,7 +169,7 @@
    private Map<String, Object> merchant(Long merchantId) {
        List<Map<String, Object>> merchants = rows("""
                select id, merchant_name, address, cover_url, business_status, updated_at
                select id, merchant_name, address, cover_url, business_status, status, updated_at
                from merchant where id = ? and deleted = 0
                """, merchantId);
        if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商户不存在");
@@ -234,6 +236,22 @@
        return "closed".equals(status) || "休息中".equals(status) ? "closed" : "open";
    }
    private boolean isBusinessOpen(Map<String, Object> merchant, Long storeId) {
        if (!Long.valueOf(1).equals(number(merchant.get("status"))) || "closed".equals(businessStatus(merchant.get("businessStatus")))) {
            return false;
        }
        Long activePeriods = jdbc.queryForObject("""
                select count(*)
                from merchant_business_hours
                where merchant_id = ? and store_id = ?
                  and (
                    (start_time <= end_time and current_time() >= start_time and current_time() < end_time)
                    or (start_time > end_time and (current_time() >= start_time or current_time() < end_time))
                  )
                """, Long.class, merchant.get("id"), storeId);
        return activePeriods != null && activePeriods > 0;
    }
    private String auditStatus(Long status) {
        if (status != null && status == 2) return "approved";
        if (status != null && status == 3) return "rejected";
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentCallbackController.java
@@ -1,6 +1,11 @@
package com.talentcard.server.business.payment;
import java.util.Map;
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.merchant.HuifuMerchantWithdrawalService;
import com.talentcard.server.business.payment.huifu.HuifuSignatureService;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -14,13 +19,28 @@
public class PaymentCallbackController {
    private final HuifuPaymentCallbackService callbackService;
    private final HuifuMerchantWithdrawalService withdrawalService;
    private final HuifuSignatureService signatureService;
    public PaymentCallbackController(HuifuPaymentCallbackService callbackService) {
    public PaymentCallbackController(HuifuPaymentCallbackService callbackService, HuifuMerchantWithdrawalService withdrawalService, HuifuSignatureService signatureService) {
        this.callbackService = callbackService;
        this.withdrawalService = withdrawalService;
        this.signatureService = signatureService;
    }
    @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> notify(@RequestParam Map<String, String> form) {
        return ResponseEntity.ok(callbackService.handle(form.get("resp_data"), form.get("sign")));
    }
    @PostMapping(value = "/withdrawal-notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
    public ResponseEntity<String> withdrawalNotify(@RequestParam Map<String, String> form) {
        String payload = form.get("resp_data"), signature = form.get("sign");
        if (!signatureService.verifyCallbackPayload(payload, signature)) throw new BusinessException(ErrorCode.FORBIDDEN, "Invalid Huifu withdrawal callback signature");
        @SuppressWarnings("unchecked") Map<String, Object> data = JSON.parseObject(payload, Map.class);
        String no = data.get("req_seq_id") == null ? "" : String.valueOf(data.get("req_seq_id")).trim();
        if (no.isBlank() || !withdrawalService.exists(no)) throw new BusinessException(ErrorCode.NOT_FOUND, "Unknown Huifu withdrawal callback");
        withdrawalService.handleCallback(no, data);
        return ResponseEntity.ok("RECV_ORD_ID_" + no);
    }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -14,6 +14,9 @@
    private String privateKey = "";
    private String platformPublicKey = "";
    private String notifyUrl = "";
    private String withdrawalProductId = "";
    private String withdrawalNotifyUrl = "";
    private String withdrawalIntoAccountDateType = "D0";
    private boolean delayAccount;
    public boolean isEnabled() { return enabled; }
@@ -34,6 +37,12 @@
    public void setPlatformPublicKey(String platformPublicKey) { this.platformPublicKey = platformPublicKey; }
    public String getNotifyUrl() { return notifyUrl; }
    public void setNotifyUrl(String notifyUrl) { this.notifyUrl = notifyUrl; }
    public String getWithdrawalProductId() { return withdrawalProductId; }
    public void setWithdrawalProductId(String withdrawalProductId) { this.withdrawalProductId = withdrawalProductId; }
    public String getWithdrawalNotifyUrl() { return withdrawalNotifyUrl; }
    public void setWithdrawalNotifyUrl(String withdrawalNotifyUrl) { this.withdrawalNotifyUrl = withdrawalNotifyUrl; }
    public String getWithdrawalIntoAccountDateType() { return withdrawalIntoAccountDateType; }
    public void setWithdrawalIntoAccountDateType(String withdrawalIntoAccountDateType) { this.withdrawalIntoAccountDateType = withdrawalIntoAccountDateType; }
    public boolean isDelayAccount() { return delayAccount; }
    public void setDelayAccount(boolean delayAccount) { this.delayAccount = delayAccount; }
}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalCommand.java
New file
@@ -0,0 +1,3 @@
package com.talentcard.server.business.payment.huifu;
public record HuifuWithdrawalCommand(String withdrawalNo, long amountCents, String huifuId, String tokenNo) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuWithdrawalGateway.java
New file
@@ -0,0 +1,57 @@
package com.talentcard.server.business.payment.huifu;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.huifu.bspay.sdk.opps.core.request.V2TradeSettlementEncashmentRequest;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class HuifuWithdrawalGateway {
    private static final Object SDK_LOCK = new Object();
    private static final Logger log = LoggerFactory.getLogger(HuifuWithdrawalGateway.class);
    private final HuifuPaymentProperties properties;
    public HuifuWithdrawalGateway(HuifuPaymentProperties properties) { this.properties = properties; }
    public Map<String, Object> create(HuifuWithdrawalCommand command) {
        ensureConfigured();
        V2TradeSettlementEncashmentRequest request = new V2TradeSettlementEncashmentRequest();
        request.setReqDate(LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
        request.setReqSeqId(command.withdrawalNo());
        request.setCashAmt(BigDecimal.valueOf(command.amountCents(), 2).toPlainString());
        request.setHuifuId(command.huifuId());
        request.setIntoAcctDateType(properties.getWithdrawalIntoAccountDateType().trim());
        request.setTokenNo(command.tokenNo());
        Map<String, Object> extension = new LinkedHashMap<>();
        if (!blank(properties.getWithdrawalNotifyUrl())) extension.put("notify_url", properties.getWithdrawalNotifyUrl().trim());
        request.setExtendInfo(extension);
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
                return BasePayClient.request(request, false);
            }
        } catch (BusinessException exception) { throw exception; }
        catch (Exception exception) {
            log.warn("Huifu withdrawal request failed: type={}, message={}", exception.getClass().getSimpleName(), safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException("Huifu withdrawal request failed", unknown(exception), exception);
        }
    }
    private void ensureConfigured() {
        if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
        if (blank(properties.getSystemId()) || blank(properties.getWithdrawalProductId()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu withdrawal configuration is incomplete");
        if (!"D0".equals(properties.getWithdrawalIntoAccountDateType()) && !"T1".equals(properties.getWithdrawalIntoAccountDateType()) && !"D1".equals(properties.getWithdrawalIntoAccountDateType()) && !"DM".equals(properties.getWithdrawalIntoAccountDateType())) throw new BusinessException(ErrorCode.BAD_REQUEST, "Invalid Huifu withdrawal arrival date type");
    }
    private void initializeSdk() throws Exception { BasePay.debug = false; BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) ? BasePay.MODE_PROD : BasePay.MODE_TEST; MerConfig config = new MerConfig(); config.setSysId(properties.getSystemId().trim()); config.setProductId(properties.getWithdrawalProductId().trim()); config.setRsaPrivateKey(properties.getPrivateKey().trim()); config.setRsaPublicKey(properties.getPlatformPublicKey().trim()); BasePay.initWithMerConfig(config); }
    private boolean blank(String value) { return value == null || value.isBlank(); }
    private boolean unknown(Exception exception) { String value = safe(exception.getMessage()).toLowerCase(); return value.contains("timeout") || value.contains("no response"); }
    private String safe(String value) { return value == null ? "" : value.substring(0, Math.min(value.length(), 300)); }
}
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentCreateRequest.java
@@ -1,6 +1,7 @@
package com.talentcard.server.business.talent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -9,15 +10,18 @@
        @NotBlank
        @Size(max = 64)
        String name,
        @Size(max = 20)
        @NotBlank(message = "请输入手机号码")
        @Pattern(regexp = "^1\\d{10}$", message = "手机号须为11位数字")
        String mobile,
        @Pattern(regexp = "^$|^[0-9A-Z]{18}$", message = "身份证号须为18位数字或大写英文字母")
        @NotBlank(message = "请输入身份证号")
        @Pattern(regexp = "^[0-9A-Z]{18}$", message = "身份证号须为18位数字或大写英文字母")
        String idCardNo,
        @NotNull(message = "请选择性别")
        Byte gender,
        @Size(max = 128)
        String workUnit,
        @NotBlank(message = "请选择人才等级")
        @Pattern(regexp = "^(卓越人才卡|杰出人才卡|普通人才卡|普通游客)$", message = "人才等级无效")
        @Pattern(regexp = "^(卓越人才卡|杰出人才卡|菁英人才卡|普通游客)$", message = "人才等级无效")
        String talentLevel,
        @NotBlank(message = "请输入人才卡号")
        @Pattern(regexp = "^[0-9A-Z-]{1,64}$", message = "人才卡号只能包含大写字母、数字和短横线")
@@ -25,8 +29,10 @@
        @NotBlank(message = "请选择本地或外地")
        @Pattern(regexp = "^(LOCAL|OUTSIDE)$", message = "来源地无效")
        String sourceAreaType,
        @NotBlank(message = "请输入工作地址")
        @Size(max = 255)
        String workAddress,
        @NotBlank(message = "请输入联系地址")
        @Size(max = 255)
        String contactAddress
) {
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentLevelUpdateRequest.java
@@ -6,7 +6,7 @@
public record TalentLevelUpdateRequest(
        @NotBlank(message = "请选择人才等级")
        @Pattern(
                regexp = "^(卓越人才卡|杰出人才卡|普通人才卡|普通游客)$",
                regexp = "^(卓越人才卡|杰出人才卡|菁英人才卡|普通游客)$",
                message = "人才等级无效"
        )
        String talentLevel
talent-card-server/src/main/java/com/talentcard/server/business/talent/TalentService.java
@@ -31,10 +31,13 @@
@Service
public class TalentService {
    private static final Set<String> TALENT_LEVELS = Set.of("卓越人才卡", "杰出人才卡", "菁英人才卡", "普通游客");
    private static final String[] IMPORT_HEADERS = {
            "姓名", "手机号", "身份证号", "性别", "卡片等级", "人才卡号", "外地/本地", "工作单位", "工作地址", "联系地址"
            "姓名(必填)", "手机号码(必填)", "身份证号(必填)", "性别(必填)", "卡片等级(必填)", "人才卡号(必填)",
            "外地/本地(必填)", "工作单位(必填)", "工作地址(必填)", "联系地址(必填)", "服务专员(必填)"
    };
    private static final Set<String> TALENT_LEVELS = Set.of("卓越人才卡", "杰出人才卡", "普通人才卡", "普通游客");
    private record ImportTalentRow(TalentCreateRequest request, Long advisorId) {}
    private final JdbcTemplate jdbcTemplate;
@@ -327,7 +330,7 @@
                headerRow.createCell(index).setCellValue(IMPORT_HEADERS[index]);
                headerRow.getCell(index).setCellStyle(headerStyle);
            }
            int[] widths = { 16, 18, 24, 10, 18, 20, 14, 24, 28, 28 };
            int[] widths = { 16, 18, 24, 10, 18, 20, 14, 24, 28, 28, 18 };
            for (int index = 0; index < widths.length; index++) {
                sheet.setColumnWidth(index, widths[index] * 256);
            }
@@ -343,6 +346,7 @@
    public Map<String, Object> importExcel(InputStream inputStream) {
        List<Map<String, Object>> failures = new ArrayList<>();
        List<TalentCreateRequest> validRows = new ArrayList<>();
        List<Long> advisorIds = new ArrayList<>();
        Set<String> fileMobiles = new HashSet<>();
        Set<String> fileIdCards = new HashSet<>();
        Set<String> fileCardNos = new HashSet<>();
@@ -373,15 +377,22 @@
                String workUnit = cellText(row, 7, formatter);
                String workAddress = cellText(row, 8, formatter);
                String contactAddress = cellText(row, 9, formatter);
                String advisorName = cellText(row, 10, formatter);
                String error = validateImportRow(name, mobile, idCardNo, gender, level, talentCardNo, area, workUnit, workAddress,
                        contactAddress, fileMobiles, fileIdCards, fileCardNos);
                if (error != null) {
                    failures.add(importFailure(rowIndex + 1, name, error));
                    continue;
                }
                Long advisorId = advisorIdByName(advisorName);
                if (advisorId == null) {
                    failures.add(importFailure(rowIndex + 1, name, advisorName.isBlank() ? "服务专员不能为空" : "服务专员不存在、已停用或名称不唯一"));
                    continue;
                }
                fileMobiles.add(mobile);
                fileIdCards.add(idCardNo);
                fileCardNos.add(talentCardNo);
                advisorIds.add(advisorId);
                validRows.add(new TalentCreateRequest(null, name, mobile, idCardNo, genderValue(gender), workUnit, level, talentCardNo,
                        "外地".equals(area) ? "OUTSIDE" : "LOCAL", workAddress, contactAddress));
            }
@@ -394,8 +405,9 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "Excel 文件无法解析,请使用下载的导入模板");
        }
        for (TalentCreateRequest request : validRows) {
            create(request);
        for (int index = 0; index < validRows.size(); index++) {
            Map<String, Object> created = create(validRows.get(index));
            updateAdvisor(((Number) created.get("id")).longValue(), advisorIds.get(index));
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("successCount", validRows.size());
@@ -473,11 +485,21 @@
            String talentCardNo, String area, String workUnit, String workAddress, String contactAddress,
            Set<String> fileMobiles, Set<String> fileIdCards, Set<String> fileCardNos) {
        if (name.isBlank()) return "姓名不能为空";
        if (mobile.isBlank()) return "手机号不能为空";
        if (idCardNo.isBlank()) return "身份证号不能为空";
        if (gender.isBlank()) return "性别不能为空";
        if (level.isBlank()) return "卡片等级不能为空";
        if (talentCardNo.isBlank()) return "人才卡号不能为空";
        if (area.isBlank()) return "外地/本地不能为空";
        if (workUnit.isBlank()) return "工作单位不能为空";
        if (workAddress.isBlank()) return "工作地址不能为空";
        if (contactAddress.isBlank()) return "联系地址不能为空";
        if (name.isBlank()) return "姓名不能为空";
        if (name.length() > 64) return "姓名不能超过64个字符";
        if (!mobile.matches("^1\\d{10}$")) return "手机号须为11位数字";
        if (!idCardNo.matches("^[0-9A-Z]{18}$")) return "身份证号须为18位数字或大写英文字母";
        if (!gender.isBlank() && !Set.of("男", "女").contains(gender)) return "性别只能填写男或女";
        if (!TALENT_LEVELS.contains(level)) return "卡片等级须填写卓越人才卡、杰出人才卡、普通人才卡或普通游客";
        if (!TALENT_LEVELS.contains(level)) return "卡片等级须填写卓越人才卡、杰出人才卡、菁英人才卡或普通游客";
        if (!talentCardNo.matches("^[0-9A-Z-]{1,64}$")) return "人才卡号只能包含大写字母、数字和短横线";
        if (!area.isBlank() && !Set.of("本地", "外地").contains(area)) return "外地/本地只能填写本地或外地";
        if (workUnit.length() > 128) return "工作单位不能超过128个字符";
@@ -495,6 +517,14 @@
        return null;
    }
    private Long advisorIdByName(String advisorName) {
        if (advisorName == null || advisorName.isBlank()) return null;
        List<Long> ids = jdbcTemplate.queryForList(
                "select id from talent_card_advisor where status = 1 and advisor_name = ?",
                Long.class, advisorName.trim());
        return ids.size() == 1 ? ids.get(0) : null;
    }
    private Byte genderValue(String gender) {
        if ("男".equals(gender)) return 1;
        if ("女".equals(gender)) return 2;
talent-card-server/src/main/resources/application.yml
@@ -61,6 +61,9 @@
      private-key: ${HUIFU_PRIVATE_KEY:}
      platform-public-key: ${HUIFU_PLATFORM_PUBLIC_KEY:}
      notify-url: ${HUIFU_NOTIFY_URL:}
      withdrawal-product-id: ${HUIFU_WITHDRAWAL_PRODUCT_ID:}
      withdrawal-notify-url: ${HUIFU_WITHDRAWAL_NOTIFY_URL:}
      withdrawal-into-account-date-type: ${HUIFU_WITHDRAWAL_INTO_ACCOUNT_DATE_TYPE:D0}
      delay-account: ${HUIFU_DELAY_ACCOUNT:false}
      reconciliation:
        enabled: ${HUIFU_RECONCILIATION_ENABLED:false}
talent-card-server/src/main/resources/db/migration/V82__admin_role_permissions.sql
New file
@@ -0,0 +1 @@
alter table admin_role add column permission_codes text default null after remark;
talent-card-server/src/main/resources/db/migration/V83__admin_operator_profile_fields.sql
New file
@@ -0,0 +1,3 @@
alter table admin_operator add column mobile varchar(20) default null after display_name;
alter table admin_operator add column position_name varchar(64) default null after role_code;
alter table admin_operator add column customer_wechat varchar(64) default null after position_name;
talent-card-server/src/main/resources/db/migration/V84__huifu_withdrawal_binding.sql
New file
@@ -0,0 +1,5 @@
alter table merchant_onboarding_application add column provider_huifu_id varchar(32) default null after status;
alter table merchant_bank_card add column provider_token_no varchar(32) default null after status;
alter table merchant_withdrawal add column provider_request_date char(8) default null after provider_trade_no;
alter table merchant_withdrawal add column provider_response_code varchar(64) default null after provider_request_date;
alter table merchant_withdrawal add column provider_response_message varchar(512) default null after provider_response_code;
talent-card-server/src/main/resources/db/migration/V85__merchant_rights_configuration.sql
New file
@@ -0,0 +1,2 @@
alter table merchant
    add column merchant_rights_config json default null comment 'merchant talent-card rights configuration';
talent-card-server/src/main/resources/db/migration/V86__rename_standard_talent_card_to_elite.sql
New file
@@ -0,0 +1,7 @@
update talent_card
set card_level = '菁英人才卡'
where card_level = '普通人才卡';
update talent_user
set talent_level = '菁英人才卡'
where talent_level = '普通人才卡';