准备上传
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-table :data="list" style="width:100%" stripe>
|
<el-table :data="list" style="width:100%">
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="name" label="模板名" />
|
<el-table-column prop="name" label="模板名" />
|
||||||
<el-table-column prop="categoryId" label="分类ID" width="100" />
|
<el-table-column prop="categoryId" label="分类ID" width="100" />
|
||||||
@@ -39,18 +39,22 @@
|
|||||||
<el-dialog v-model="dlg.visible" :title="dlg.id? '查看模板' : '新建模板'" width="1100">
|
<el-dialog v-model="dlg.visible" :title="dlg.id? '查看模板' : '新建模板'" width="1100">
|
||||||
<el-form :model="form" label-width="100px">
|
<el-form :model="form" label-width="100px">
|
||||||
<el-form-item label="分类">
|
<el-form-item label="分类">
|
||||||
<el-select v-model="form.categoryId" placeholder="选择分类" :disabled="!!dlg.id">
|
<el-select v-if="!dlg.id" v-model="form.categoryId" placeholder="选择分类">
|
||||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<span v-else class="view-text">{{ categories.find(c=>c.id===form.categoryId)?.name || '-' }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模板名">
|
<el-form-item label="模板名">
|
||||||
<el-input v-model="form.name" maxlength="120" :disabled="!!dlg.id" />
|
<el-input v-if="!dlg.id" v-model="form.name" maxlength="120" />
|
||||||
|
<span v-else class="view-text">{{ form.name || '-' }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="型号规则">
|
<el-form-item label="型号规则">
|
||||||
<el-input v-model="form.modelRule" placeholder="可填备注或正则" :disabled="!!dlg.id" />
|
<el-input v-if="!dlg.id" v-model="form.modelRule" placeholder="可填备注或正则" />
|
||||||
|
<span v-else class="view-text">{{ form.modelRule || '-' }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" :disabled="!!dlg.id" />
|
<el-switch v-if="!dlg.id" v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||||
|
<el-tag v-else :type="form.status===1?'success':'info'">{{ form.status===1?'启用':'停用' }}</el-tag>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-divider>参数字段</el-divider>
|
<el-divider>参数字段</el-divider>
|
||||||
<div v-if="!dlg.id">
|
<div v-if="!dlg.id">
|
||||||
@@ -61,38 +65,53 @@
|
|||||||
<template #default="{ $index }">{{ $index+1 }}</template>
|
<template #default="{ $index }">{{ $index+1 }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="名称">
|
<el-table-column label="名称">
|
||||||
<template #default="{row}"><el-input v-model="row.fieldLabel" :disabled="!!dlg.id" @input="onLabelInput(row)" /></template>
|
<template #default="{row}">
|
||||||
|
<el-input v-if="!dlg.id" v-model="row.fieldLabel" @input="onLabelInput(row)" />
|
||||||
|
<span v-else class="view-text">{{ row.fieldLabel || '-' }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="类型" width="120">
|
<el-table-column label="类型" width="120">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-select v-model="row.type" style="width:110px" :disabled="!!dlg.id">
|
<el-select v-if="!dlg.id" v-model="row.type" style="width:110px">
|
||||||
<el-option label="数字" value="number" />
|
<el-option label="数字" value="number" />
|
||||||
<el-option label="非数字" value="string" />
|
<el-option label="非数字" value="string" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<span v-else class="view-text">{{ row.type==='number'?'数字':'非数字' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="必填" width="80">
|
<el-table-column label="必填" width="80">
|
||||||
<template #default="{row}"><el-switch v-model="row.required" :disabled="!!dlg.id" /></template>
|
<template #default="{row}">
|
||||||
|
<el-switch v-if="!dlg.id" v-model="row.required" />
|
||||||
|
<el-tag v-else :type="row.required?'success':'info'" size="small">{{ row.required?'是':'否' }}</el-tag>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="单位" width="120">
|
<el-table-column label="单位" width="120">
|
||||||
<template #default="{row}"><el-input v-model="row.unit" :disabled="!!dlg.id" /></template>
|
<template #default="{row}">
|
||||||
|
<el-input v-if="!dlg.id" v-model="row.unit" />
|
||||||
|
<span v-else class="view-text">{{ row.unit || '-' }}</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<!-- 检索默认参与:前端不再显示开关 -->
|
<!-- 检索默认参与:前端不再显示开关 -->
|
||||||
<el-table-column label="可模糊" width="90">
|
<el-table-column label="可模糊" width="90">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-switch v-model="row.fuzzySearchable" :disabled="!!dlg.id || row.type!=='number'" />
|
<el-switch v-if="!dlg.id" v-model="row.fuzzySearchable" :disabled="row.type!=='number'" />
|
||||||
|
<el-tag v-else :type="row.fuzzySearchable?'success':'info'" size="small">{{ row.fuzzySearchable?'是':'否' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="卡片展示" width="100">
|
<el-table-column label="卡片展示" width="100">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-switch v-model="row.cardDisplay" :disabled="!!dlg.id" />
|
<el-switch v-if="!dlg.id" v-model="row.cardDisplay" />
|
||||||
|
<el-tag v-else :type="row.cardDisplay?'success':'info'" size="small">{{ row.cardDisplay?'是':'否' }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="容差" width="160">
|
<el-table-column label="容差" width="160">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-input v-model="row.fuzzyTolerance" :disabled="!!dlg.id || row.type!=='number' || !row.fuzzySearchable" placeholder="默认1.0" />
|
<template v-if="!dlg.id">
|
||||||
<div v-if="row.unit" style="font-size:12px;color:#999">单位:{{ row.unit }}</div>
|
<el-input v-model="row.fuzzyTolerance" :disabled="row.type!=='number' || !row.fuzzySearchable" placeholder="默认1.0" />
|
||||||
|
<div v-if="row.unit" style="font-size:12px;color:#999">单位:{{ row.unit }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="view-text">{{ row.fuzzyTolerance || '默认1.0' }}<span v-if="row.unit"> {{ row.unit }}</span></span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@@ -255,6 +274,14 @@ function uniqueKey(base: string, currentRow:any){
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page { padding: 16px; }
|
.page { padding: 16px; }
|
||||||
.header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 12px; }
|
.header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* 查看模式下的文本样式 */
|
||||||
|
.view-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 32px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
39
backend/.env
39
backend/.env
@@ -1,8 +1,33 @@
|
|||||||
|
# 数据库配置
|
||||||
|
DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&connectionCollation=utf8mb4_0900_ai_ci
|
||||||
|
DB_HOST=mysql.tonaspace.com
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=partsinquiry
|
||||||
|
DB_USER=dp
|
||||||
|
DB_PASSWORD=tHT5EcPT5WY6FfcK
|
||||||
|
|
||||||
WECHAT_MP_APP_ID=wx8c514804683e4be4
|
# 163 邮件SMTP配置
|
||||||
WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899
|
MAIL_HOST=smtp.163.com
|
||||||
WECHAT_MP_TOKEN_CACHE_SECONDS=6900
|
MAIL_PORT=465
|
||||||
JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs=
|
MAIL_PROTOCOL=smtps
|
||||||
DB_USER=root
|
MAIL_USERNAME=sdssds@163.com
|
||||||
DB_PASSWORD=TONA1234
|
MAIL_PASSWORD=NQLihrab8vGiAjiE
|
||||||
DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci
|
MAIL_FROM=sdssds@163.com
|
||||||
|
MAIL_SUBJECT_PREFIX=[配件查询]
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# Python 条码服务配置(由 Java 自动拉起)
|
||||||
|
PY_BARCODE_ENABLED=true
|
||||||
|
PY_BARCODE_WORKDIR=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm
|
||||||
|
PY_BARCODE_PYTHON=python
|
||||||
|
PY_BARCODE_APP_MODULE=app.server.main
|
||||||
|
PY_BARCODE_USE_MODULE=true
|
||||||
|
PY_BARCODE_HOST=127.0.0.1
|
||||||
|
PY_BARCODE_PORT=8000
|
||||||
|
PY_BARCODE_TIMEOUT=20
|
||||||
|
PY_BARCODE_MAX_UPLOAD_MB=8
|
||||||
|
|
||||||
|
# 可选:将 Python 输出写入文件
|
||||||
|
PY_BARCODE_LOG=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm\debug_out\txm.log
|
||||||
@@ -1,8 +1,33 @@
|
|||||||
|
# 数据库配置
|
||||||
|
DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&connectionCollation=utf8mb4_0900_ai_ci
|
||||||
|
DB_HOST=mysql.tonaspace.com
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=partsinquiry
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=TONA1234
|
||||||
|
|
||||||
WECHAT_MP_APP_ID=wx8c514804683e4be4
|
# 163 邮件SMTP配置
|
||||||
WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899
|
MAIL_HOST=smtp.163.com
|
||||||
WECHAT_MP_TOKEN_CACHE_SECONDS=6900
|
MAIL_PORT=465
|
||||||
JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs=
|
MAIL_PROTOCOL=smtps
|
||||||
spring.datasource.username=${DB_USER:root}
|
MAIL_USERNAME=sdssds@163.com
|
||||||
spring.datasource.password=${DB_PASSWORD:TONA1234}
|
MAIL_PASSWORD=NQLihrab8vGiAjiE
|
||||||
spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci}
|
MAIL_FROM=sdssds@163.com
|
||||||
|
MAIL_SUBJECT_PREFIX=[配件查询]
|
||||||
|
|
||||||
|
# CORS配置
|
||||||
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# Python 条码服务配置(由 Java 自动拉起)
|
||||||
|
PY_BARCODE_ENABLED=true
|
||||||
|
PY_BARCODE_WORKDIR=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm
|
||||||
|
PY_BARCODE_PYTHON=python
|
||||||
|
PY_BARCODE_APP_MODULE=app.server.main
|
||||||
|
PY_BARCODE_USE_MODULE=true
|
||||||
|
PY_BARCODE_HOST=127.0.0.1
|
||||||
|
PY_BARCODE_PORT=8000
|
||||||
|
PY_BARCODE_TIMEOUT=20
|
||||||
|
PY_BARCODE_MAX_UPLOAD_MB=8
|
||||||
|
|
||||||
|
|
||||||
|
PY_BARCODE_LOG=C:\Users\21826\Desktop\wj\PartsInquiry\backend\txm\debug_out\txm.log
|
||||||
35
backend/.gitignore
vendored
35
backend/.gitignore
vendored
@@ -1,3 +1,38 @@
|
|||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
|
||||||
|
# Environment files (do not commit real secrets)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!env.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
run.log
|
||||||
|
|
||||||
|
# OS/IDE
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Python caches (txm)
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
txm/venv/
|
||||||
|
txm/.venv/
|
||||||
|
txm/debug_out/
|
||||||
|
|
||||||
|
# Data (runtime uploads, keep out of source bundle)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Archives produced by scripts
|
||||||
|
backend_source_*.zip
|
||||||
HELP.md
|
HELP.md
|
||||||
target/
|
target/
|
||||||
.mvn/wrapper/maven-wrapper.jar
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|||||||
2
backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
2
backend/.stage-src-20251004-193018/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||||
565
backend/.stage-src-20251004-193018/db/db.sql
Normal file
565
backend/.stage-src-20251004-193018/db/db.sql
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 配件查询 App 数据库结构(MySQL 8.0)
|
||||||
|
-- 依据:/doc/requirements.md、/doc/functional_spec.md、/doc/architecture.md
|
||||||
|
-- 注意:不在此文件中创建数据库与用户,请在外部以环境配置完成
|
||||||
|
-- 字符集统一为 utf8mb4,排序规则 utf8mb4_0900_ai_ci
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET time_zone = '+00:00';
|
||||||
|
SET sql_mode = 'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 基础:租户与用户
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shops (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '店铺/租户ID',
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '店铺名称',
|
||||||
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_shops_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='店铺/租户';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL COMMENT '所属店铺',
|
||||||
|
phone VARCHAR(32) NULL COMMENT '手机号',
|
||||||
|
name VARCHAR(64) NOT NULL COMMENT '姓名',
|
||||||
|
role VARCHAR(32) NOT NULL DEFAULT 'staff' COMMENT '角色:owner/staff/finance/...',
|
||||||
|
password_hash VARCHAR(255) NULL COMMENT '密码哈希(若采用短信登录可为空)',
|
||||||
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用',
|
||||||
|
is_owner TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否店主',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_users_shop_phone (shop_id, phone),
|
||||||
|
KEY idx_users_shop (shop_id),
|
||||||
|
CONSTRAINT fk_users_shop FOREIGN KEY (shop_id) REFERENCES shops(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户';
|
||||||
|
|
||||||
|
-- 第三方身份映射(微信)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_identities (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
provider ENUM('wechat_mp','wechat_app') NOT NULL COMMENT '身份提供方:小程序/APP',
|
||||||
|
openid VARCHAR(64) NOT NULL,
|
||||||
|
unionid VARCHAR(64) NULL,
|
||||||
|
nickname VARCHAR(64) NULL,
|
||||||
|
avatar_url VARCHAR(512) NULL,
|
||||||
|
last_login_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_identity_provider_openid (provider, openid),
|
||||||
|
UNIQUE KEY ux_identity_unionid (unionid),
|
||||||
|
KEY idx_identity_user (user_id),
|
||||||
|
KEY idx_identity_shop (shop_id),
|
||||||
|
CONSTRAINT fk_identity_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方身份映射(微信)';
|
||||||
|
|
||||||
|
-- 微信会话(小程序/APP 临时会话)
|
||||||
|
CREATE TABLE IF NOT EXISTS wechat_sessions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
provider ENUM('wechat_mp','wechat_app') NOT NULL,
|
||||||
|
openid VARCHAR(64) NOT NULL,
|
||||||
|
session_key VARCHAR(128) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_wechat_session (provider, openid),
|
||||||
|
KEY idx_wechat_session_expires (expires_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='微信会话(临时)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_parameters (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL COMMENT '创建/最后修改人',
|
||||||
|
`key` VARCHAR(64) NOT NULL COMMENT '参数键',
|
||||||
|
`value` JSON NOT NULL COMMENT '参数值(JSON)',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_sysparams_shop_key (shop_id, `key`),
|
||||||
|
KEY idx_sysparams_shop (shop_id),
|
||||||
|
CONSTRAINT fk_sysparams_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_sysparams_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统参数(租户级)';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 货品域(含价格/库存/图片/别名)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_categories (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
parent_id BIGINT UNSIGNED NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_categories_shop_name (shop_id, name),
|
||||||
|
KEY idx_categories_shop (shop_id),
|
||||||
|
KEY idx_categories_parent (parent_id),
|
||||||
|
CONSTRAINT fk_categories_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES product_categories(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品类别';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_units (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(16) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_units_shop_name (shop_id, name),
|
||||||
|
KEY idx_units_shop (shop_id),
|
||||||
|
CONSTRAINT fk_units_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_units_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品单位';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS global_skus (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(120) NOT NULL COMMENT 'SKU名称',
|
||||||
|
brand VARCHAR(64) NULL,
|
||||||
|
model VARCHAR(64) NULL,
|
||||||
|
spec VARCHAR(128) NULL,
|
||||||
|
barcode VARCHAR(32) NULL,
|
||||||
|
unit_id BIGINT UNSIGNED NULL,
|
||||||
|
tags JSON NULL,
|
||||||
|
status ENUM('published','offline') NOT NULL DEFAULT 'published',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_global_skus_barcode (barcode),
|
||||||
|
KEY idx_global_skus_brand_model (brand, model),
|
||||||
|
CONSTRAINT fk_globalsku_unit FOREIGN KEY (unit_id) REFERENCES product_units(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全局SKU(众包)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
category_id BIGINT UNSIGNED NULL,
|
||||||
|
-- unit_id 已移除
|
||||||
|
brand VARCHAR(64) NULL,
|
||||||
|
model VARCHAR(64) NULL,
|
||||||
|
spec VARCHAR(128) NULL,
|
||||||
|
origin VARCHAR(64) NULL,
|
||||||
|
barcode VARCHAR(32) NULL,
|
||||||
|
alias VARCHAR(120) NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
global_sku_id BIGINT UNSIGNED NULL,
|
||||||
|
safe_min DECIMAL(18,3) NULL,
|
||||||
|
safe_max DECIMAL(18,3) NULL,
|
||||||
|
search_text TEXT NULL COMMENT '供全文检索的聚合字段(名称/品牌/型号/规格/别名)',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_products_shop_barcode (shop_id, barcode),
|
||||||
|
KEY idx_products_shop (shop_id),
|
||||||
|
KEY idx_products_category (category_id),
|
||||||
|
-- KEY idx_products_unit (unit_id),
|
||||||
|
FULLTEXT KEY ft_products_search (name, brand, model, spec, search_text),
|
||||||
|
CONSTRAINT fk_products_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_products_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES product_categories(id),
|
||||||
|
-- CONSTRAINT fk_products_unit FOREIGN KEY (unit_id) REFERENCES product_units(id),
|
||||||
|
CONSTRAINT fk_products_globalsku FOREIGN KEY (global_sku_id) REFERENCES global_skus(id),
|
||||||
|
CONSTRAINT ck_products_safe_range CHECK (safe_min IS NULL OR safe_max IS NULL OR safe_min <= safe_max)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_aliases (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
alias VARCHAR(120) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_product_alias (product_id, alias),
|
||||||
|
KEY idx_product_alias_product (product_id),
|
||||||
|
CONSTRAINT fk_alias_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_alias_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_alias_product FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品别名';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_prices (
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
purchase_price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
retail_price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
distribution_price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
wholesale_price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
big_client_price DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (product_id),
|
||||||
|
KEY idx_prices_shop (shop_id),
|
||||||
|
CONSTRAINT fk_prices_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_prices_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_prices_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT ck_prices_non_negative CHECK (
|
||||||
|
purchase_price >= 0 AND retail_price >= 0 AND distribution_price >= 0 AND wholesale_price >= 0 AND big_client_price >= 0
|
||||||
|
)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品价格(含四列销售价)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventories (
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
quantity DECIMAL(18,3) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (product_id),
|
||||||
|
KEY idx_inventories_shop (shop_id),
|
||||||
|
CONSTRAINT fk_inv_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_inv_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_inv_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT ck_inv_qty_non_negative CHECK (quantity >= 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库存';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_images (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
url VARCHAR(512) NOT NULL,
|
||||||
|
hash VARCHAR(64) NULL COMMENT '内容哈希(去重)',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_product_image_hash (product_id, hash),
|
||||||
|
KEY idx_product_images_product (product_id),
|
||||||
|
CONSTRAINT fk_pimg_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_pimg_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_pimg_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商品图片';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 往来单位与账户
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
phone VARCHAR(32) NULL,
|
||||||
|
price_level ENUM('retail','distribution','wholesale','big_client') NOT NULL DEFAULT 'retail' COMMENT '默认售价列',
|
||||||
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_customers_shop (shop_id),
|
||||||
|
KEY idx_customers_phone (phone),
|
||||||
|
CONSTRAINT fk_customers_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_customers_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS suppliers (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
phone VARCHAR(32) NULL,
|
||||||
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_suppliers_shop (shop_id),
|
||||||
|
KEY idx_suppliers_phone (phone),
|
||||||
|
CONSTRAINT fk_suppliers_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_suppliers_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
`type` ENUM('cash','bank','alipay','wechat','other') NOT NULL DEFAULT 'cash',
|
||||||
|
balance DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||||
|
status TINYINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_accounts_shop_name (shop_id, name),
|
||||||
|
KEY idx_accounts_shop (shop_id),
|
||||||
|
CONSTRAINT fk_accounts_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='结算账户';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 单据域(销售/进货/其他收支/流水)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sales_orders (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL COMMENT '创建人',
|
||||||
|
customer_id BIGINT UNSIGNED NULL,
|
||||||
|
order_no VARCHAR(32) NOT NULL,
|
||||||
|
order_time DATETIME NOT NULL,
|
||||||
|
status ENUM('draft','approved','returned','void') NOT NULL DEFAULT 'draft',
|
||||||
|
amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应收合计',
|
||||||
|
paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已收合计',
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_sales_order_no (shop_id, order_no),
|
||||||
|
KEY idx_sales_shop_time (shop_id, order_time),
|
||||||
|
KEY idx_sales_customer (customer_id),
|
||||||
|
CONSTRAINT fk_sales_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_sales_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_sales_customer FOREIGN KEY (customer_id) REFERENCES customers(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sales_order_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
quantity DECIMAL(18,3) NOT NULL,
|
||||||
|
unit_price DECIMAL(18,2) NOT NULL,
|
||||||
|
discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '折扣百分比0-100',
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_soi_order (order_id),
|
||||||
|
KEY idx_soi_product (product_id),
|
||||||
|
CONSTRAINT fk_soi_order FOREIGN KEY (order_id) REFERENCES sales_orders(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_soi_product FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
CONSTRAINT ck_soi_qty CHECK (quantity > 0),
|
||||||
|
CONSTRAINT ck_soi_price CHECK (unit_price >= 0),
|
||||||
|
CONSTRAINT ck_soi_discount CHECK (discount_rate >= 0 AND discount_rate <= 100)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='销售单明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_orders (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
supplier_id BIGINT UNSIGNED NULL,
|
||||||
|
order_no VARCHAR(32) NOT NULL,
|
||||||
|
order_time DATETIME NOT NULL,
|
||||||
|
status ENUM('draft','approved','void') NOT NULL DEFAULT 'draft',
|
||||||
|
amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '应付合计',
|
||||||
|
paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '已付合计',
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_purchase_order_no (shop_id, order_no),
|
||||||
|
KEY idx_purchase_shop_time (shop_id, order_time),
|
||||||
|
KEY idx_purchase_supplier (supplier_id),
|
||||||
|
CONSTRAINT fk_purchase_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_purchase_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_purchase_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_order_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
quantity DECIMAL(18,3) NOT NULL,
|
||||||
|
unit_price DECIMAL(18,2) NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_poi_order (order_id),
|
||||||
|
KEY idx_poi_product (product_id),
|
||||||
|
CONSTRAINT fk_poi_order FOREIGN KEY (order_id) REFERENCES purchase_orders(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_poi_product FOREIGN KEY (product_id) REFERENCES products(id),
|
||||||
|
CONSTRAINT ck_poi_qty CHECK (quantity > 0),
|
||||||
|
CONSTRAINT ck_poi_price CHECK (unit_price >= 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='进货单明细';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
biz_type ENUM('sale','purchase','other') NOT NULL,
|
||||||
|
biz_id BIGINT UNSIGNED NULL COMMENT '业务表ID:sales_orders/purchase_orders/other_transactions',
|
||||||
|
account_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
direction ENUM('in','out') NOT NULL COMMENT '收款/付款',
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
pay_time DATETIME NOT NULL,
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_payments_shop_time (shop_id, pay_time),
|
||||||
|
KEY idx_payments_biz (biz_type, biz_id),
|
||||||
|
CONSTRAINT fk_payments_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_payments_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_payments_account FOREIGN KEY (account_id) REFERENCES accounts(id),
|
||||||
|
CONSTRAINT ck_payments_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='收付款记录';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS other_transactions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
`type` ENUM('income','expense') NOT NULL,
|
||||||
|
category VARCHAR(64) NOT NULL,
|
||||||
|
counterparty_type VARCHAR(32) NULL COMMENT 'customer/supplier/other',
|
||||||
|
counterparty_id BIGINT UNSIGNED NULL,
|
||||||
|
account_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
amount DECIMAL(18,2) NOT NULL,
|
||||||
|
tx_time DATETIME NOT NULL,
|
||||||
|
remark VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_ot_shop_time (shop_id, tx_time),
|
||||||
|
KEY idx_ot_account (account_id),
|
||||||
|
CONSTRAINT fk_ot_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_ot_user FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
CONSTRAINT fk_ot_account FOREIGN KEY (account_id) REFERENCES accounts(id),
|
||||||
|
CONSTRAINT ck_ot_amount CHECK (amount > 0)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='其他收入/支出';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 配件查询与审核、附件
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS part_submissions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
model_unique VARCHAR(128) NOT NULL COMMENT '型号(唯一)',
|
||||||
|
brand VARCHAR(64) NULL,
|
||||||
|
spec VARCHAR(128) NULL,
|
||||||
|
size VARCHAR(64) NULL,
|
||||||
|
aperture VARCHAR(64) NULL,
|
||||||
|
compatible TEXT NULL COMMENT '适配信息',
|
||||||
|
status ENUM('draft','pending','rejected','published') NOT NULL DEFAULT 'pending',
|
||||||
|
reason VARCHAR(255) NULL COMMENT '驳回原因',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_part_model_unique (model_unique),
|
||||||
|
KEY idx_part_submissions_shop (shop_id),
|
||||||
|
CONSTRAINT fk_part_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_part_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='配件数据提交(审核)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
shop_id BIGINT UNSIGNED NULL COMMENT '全局资源可空,本地资源属于租户',
|
||||||
|
user_id BIGINT UNSIGNED NULL,
|
||||||
|
owner_type VARCHAR(32) NOT NULL COMMENT '资源归属类型:product/part_submission/global_sku/...',
|
||||||
|
owner_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
url VARCHAR(512) NOT NULL,
|
||||||
|
hash VARCHAR(64) NULL,
|
||||||
|
meta JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY ux_attachments_hash (hash),
|
||||||
|
KEY idx_attachments_owner (owner_type, owner_id),
|
||||||
|
CONSTRAINT fk_att_shop FOREIGN KEY (shop_id) REFERENCES shops(id),
|
||||||
|
CONSTRAINT fk_att_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='通用附件(图片等)';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- 触发器:维护 products.search_text 聚合字段
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_products_ai;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER trg_products_ai AFTER INSERT ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE products
|
||||||
|
SET search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec)
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_products_au;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER trg_products_au BEFORE UPDATE ON products
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
SET NEW.search_text = CONCAT_WS(' ', NEW.name, NEW.brand, NEW.model, NEW.spec);
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 当别名变化时重建 search_text(名称/品牌/型号/规格 + 所有别名)
|
||||||
|
DROP TRIGGER IF EXISTS trg_palias_ai;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER trg_palias_ai AFTER INSERT ON product_aliases
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE products p
|
||||||
|
JOIN (
|
||||||
|
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
|
||||||
|
FROM product_aliases pa
|
||||||
|
WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL
|
||||||
|
GROUP BY pa.product_id
|
||||||
|
) a ON a.product_id = p.id
|
||||||
|
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases)
|
||||||
|
WHERE p.id = NEW.product_id;
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_palias_au;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER trg_palias_au AFTER UPDATE ON product_aliases
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE products p
|
||||||
|
JOIN (
|
||||||
|
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
|
||||||
|
FROM product_aliases pa
|
||||||
|
WHERE pa.product_id = NEW.product_id AND pa.deleted_at IS NULL
|
||||||
|
GROUP BY pa.product_id
|
||||||
|
) a ON a.product_id = p.id
|
||||||
|
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, a.aliases)
|
||||||
|
WHERE p.id = NEW.product_id;
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_palias_ad;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER trg_palias_ad AFTER DELETE ON product_aliases
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE products p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT pa.product_id, GROUP_CONCAT(pa.alias SEPARATOR ' ') AS aliases
|
||||||
|
FROM product_aliases pa
|
||||||
|
WHERE pa.product_id = OLD.product_id AND pa.deleted_at IS NULL
|
||||||
|
GROUP BY pa.product_id
|
||||||
|
) a ON a.product_id = p.id
|
||||||
|
SET p.search_text = CONCAT_WS(' ', p.name, p.brand, p.model, p.spec, COALESCE(a.aliases, ''))
|
||||||
|
WHERE p.id = OLD.product_id;
|
||||||
|
END $$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
|
||||||
295
backend/.stage-src-20251004-193018/mvnw
vendored
Normal file
295
backend/.stage-src-20251004-193018/mvnw
vendored
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.3
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
189
backend/.stage-src-20251004-193018/mvnw.cmd
vendored
Normal file
189
backend/.stage-src-20251004-193018/mvnw.cmd
vendored
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.3
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
106
backend/.stage-src-20251004-193018/pom.xml
Normal file
106
backend/.stage-src-20251004-193018/pom.xml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.5.5</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>demo</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>demo</name>
|
||||||
|
<description>Demo project for Spring Boot</description>
|
||||||
|
<url/>
|
||||||
|
<licenses>
|
||||||
|
<license/>
|
||||||
|
</licenses>
|
||||||
|
<developers>
|
||||||
|
<developer/>
|
||||||
|
</developers>
|
||||||
|
<scm>
|
||||||
|
<connection/>
|
||||||
|
<developerConnection/>
|
||||||
|
<tag/>
|
||||||
|
<url/>
|
||||||
|
</scm>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Web MVC -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JPA for MySQL access -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 邮件发送 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.2.5</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 认证:JWT 签发 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.auth0</groupId>
|
||||||
|
<artifactId>java-jwt</artifactId>
|
||||||
|
<version>4.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 密码哈希:BCrypt -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
<version>6.3.4</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- .env loader for Spring Boot -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>me.paulschwarz</groupId>
|
||||||
|
<artifactId>spring-dotenv</artifactId>
|
||||||
|
<version>4.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.example.demo;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class DemoApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(DemoApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.demo.account;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/accounts")
|
||||||
|
public class AccountController {
|
||||||
|
|
||||||
|
private final AccountService accountService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public AccountController(AccountService accountService, AppDefaultsProperties defaults) {
|
||||||
|
this.accountService = accountService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "status", required = false) Integer status,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "50") int size) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
return ResponseEntity.ok(accountService.list(sid, kw, status == null ? 1 : status, Math.max(0, page - 1), size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody AccountDtos.CreateAccountRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
Long id = accountService.create(sid, uid, req);
|
||||||
|
java.util.Map<String,Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("id", id);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody AccountDtos.CreateAccountRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
accountService.update(id, sid, uid, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/ledger")
|
||||||
|
public ResponseEntity<?> ledger(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
return ResponseEntity.ok(accountService.ledger(id, sid, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.demo.account;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public class AccountDtos {
|
||||||
|
|
||||||
|
public static class CreateAccountRequest {
|
||||||
|
public String name;
|
||||||
|
public String type; // cash, bank, alipay, wechat, other
|
||||||
|
public String bankName;
|
||||||
|
public String bankAccount;
|
||||||
|
public BigDecimal openingBalance; // 可选,创建时作为期初
|
||||||
|
public Integer status; // 1 启用 0 停用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.example.demo.account;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AccountService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AccountService(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object list(Long shopId, String kw, Integer status, int page, int size) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT id, name, type, bank_name, bank_account, balance, status FROM accounts WHERE shop_id=?");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (status != null) { sql.append(" AND status=?"); ps.add(status); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (name LIKE ? OR bank_name LIKE ? OR bank_account LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long create(Long shopId, Long userId, AccountDtos.CreateAccountRequest req) {
|
||||||
|
if (req == null || req.name == null || req.name.isBlank()) throw new IllegalArgumentException("账户名称必填");
|
||||||
|
String type = (req.type == null || req.type.isBlank()) ? "cash" : req.type.toLowerCase();
|
||||||
|
int status = req.status == null ? 1 : req.status;
|
||||||
|
jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,bank_name,bank_account,balance,status,created_at,updated_at) VALUES (?,?,?,?,?,?,0,?,NOW(),NOW())",
|
||||||
|
shopId, userId, req.name, type, req.bankName, req.bankAccount, status);
|
||||||
|
Long id = jdbcTemplate.queryForObject("SELECT id FROM accounts WHERE shop_id=? AND name=? ORDER BY id DESC LIMIT 1", Long.class, shopId, req.name);
|
||||||
|
|
||||||
|
BigDecimal opening = req.openingBalance == null ? BigDecimal.ZERO : req.openingBalance.setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
if (opening.compareTo(BigDecimal.ZERO) != 0) {
|
||||||
|
java.sql.Timestamp now = new java.sql.Timestamp(System.currentTimeMillis());
|
||||||
|
// other_transactions
|
||||||
|
String otType = opening.compareTo(BigDecimal.ZERO) > 0 ? "income" : "expense";
|
||||||
|
BigDecimal amt = opening.abs();
|
||||||
|
jdbcTemplate.update("INSERT INTO other_transactions (shop_id,user_id,type,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW())",
|
||||||
|
shopId, userId, otType, "account_operation", null, null, id, amt, now, "期初余额");
|
||||||
|
// payments
|
||||||
|
String direction = opening.compareTo(BigDecimal.ZERO) > 0 ? "in" : "out";
|
||||||
|
jdbcTemplate.update("INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,NOW())",
|
||||||
|
shopId, userId, "other", null, id, direction, amt, now, "期初余额");
|
||||||
|
// update balance
|
||||||
|
BigDecimal delta = opening;
|
||||||
|
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", delta, id, shopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void update(Long id, Long shopId, Long userId, AccountDtos.CreateAccountRequest req) {
|
||||||
|
StringBuilder sql = new StringBuilder("UPDATE accounts SET updated_at=NOW()");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
|
if (req.name != null) { sql.append(", name=?"); ps.add(req.name); }
|
||||||
|
if (req.type != null) { sql.append(", type=?"); ps.add(req.type.toLowerCase()); }
|
||||||
|
if (req.bankName != null) { sql.append(", bank_name=?"); ps.add(req.bankName); }
|
||||||
|
if (req.bankAccount != null) { sql.append(", bank_account=?"); ps.add(req.bankAccount); }
|
||||||
|
if (req.status != null) { sql.append(", status=?"); ps.add(req.status); }
|
||||||
|
sql.append(" WHERE id=? AND shop_id=?"); ps.add(id); ps.add(shopId);
|
||||||
|
jdbcTemplate.update(sql.toString(), ps.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String,Object> ledger(Long accountId, Long shopId, String kw, int page, int size, String startDate, String endDate) {
|
||||||
|
// 汇总
|
||||||
|
String baseCond = " shop_id=? AND account_id=?";
|
||||||
|
java.util.List<Object> basePs = new java.util.ArrayList<>(); basePs.add(shopId); basePs.add(accountId);
|
||||||
|
java.util.function.BiFunction<String, java.util.List<Object>, java.math.BigDecimal> sum = (sql, ps) -> {
|
||||||
|
java.math.BigDecimal v = jdbcTemplate.queryForObject(sql, java.math.BigDecimal.class, ps.toArray());
|
||||||
|
return v == null ? java.math.BigDecimal.ZERO : v;
|
||||||
|
};
|
||||||
|
String dateStart = (startDate == null || startDate.isBlank()) ? null : startDate;
|
||||||
|
String dateEnd = (endDate == null || endDate.isBlank()) ? null : endDate;
|
||||||
|
|
||||||
|
// opening = 截止开始日期前净额(仅 payments)
|
||||||
|
String payOpenSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE -amount END),0) FROM payments WHERE" + baseCond + (dateStart==null?"":" AND pay_time<?");
|
||||||
|
java.util.List<Object> payOpenPs = new java.util.ArrayList<>(basePs);
|
||||||
|
if (dateStart!=null) payOpenPs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
||||||
|
java.math.BigDecimal opening = sum.apply(payOpenSql, payOpenPs);
|
||||||
|
|
||||||
|
// 区间收入/支出(仅 payments)
|
||||||
|
String payRangeSql = "SELECT COALESCE(SUM(CASE WHEN direction='in' THEN amount ELSE 0 END),0), COALESCE(SUM(CASE WHEN direction='out' THEN amount ELSE 0 END),0) FROM payments WHERE" + baseCond +
|
||||||
|
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?");
|
||||||
|
java.util.List<Object> payRangePs = new java.util.ArrayList<>(basePs);
|
||||||
|
if (dateStart!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00"));
|
||||||
|
if (dateEnd!=null) payRangePs.add(java.sql.Timestamp.valueOf(dateEnd + " 23:59:59"));
|
||||||
|
java.util.Map<String, Object> pr = jdbcTemplate.queryForMap(payRangeSql, payRangePs.toArray());
|
||||||
|
java.math.BigDecimal payIn = (java.math.BigDecimal) pr.values().toArray()[0];
|
||||||
|
java.math.BigDecimal payOut = (java.math.BigDecimal) pr.values().toArray()[1];
|
||||||
|
|
||||||
|
java.math.BigDecimal income = payIn;
|
||||||
|
java.math.BigDecimal expense = payOut;
|
||||||
|
java.math.BigDecimal ending = opening.add(income).subtract(expense);
|
||||||
|
|
||||||
|
// 明细列表(仅 payments,按时间倒序)
|
||||||
|
String listSql = "SELECT id, biz_type AS src, pay_time AS tx_time, direction, amount, remark, biz_id, category FROM payments WHERE" + baseCond +
|
||||||
|
(dateStart==null?"":" AND pay_time>=?") + (dateEnd==null?"":" AND pay_time<=?") +
|
||||||
|
" ORDER BY tx_time DESC LIMIT ? OFFSET ?";
|
||||||
|
java.util.List<Object> lp = new java.util.ArrayList<>(basePs);
|
||||||
|
if (dateStart!=null) { lp.add(java.sql.Timestamp.valueOf(dateStart + " 00:00:00")); }
|
||||||
|
if (dateEnd!=null) { lp.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
lp.add(size); lp.add(page * size);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.queryForList(listSql, lp.toArray());
|
||||||
|
|
||||||
|
Map<String,Object> resp = new HashMap<>();
|
||||||
|
resp.put("opening", opening);
|
||||||
|
resp.put("income", income);
|
||||||
|
resp.put("expense", expense);
|
||||||
|
resp.put("ending", ending);
|
||||||
|
resp.put("list", list);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/auth")
|
||||||
|
public class AdminAuthController {
|
||||||
|
|
||||||
|
private final AdminAuthService adminAuthService;
|
||||||
|
|
||||||
|
public AdminAuthController(AdminAuthService adminAuthService) {
|
||||||
|
this.adminAuthService = adminAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody AdminAuthService.LoginRequest req) {
|
||||||
|
var resp = adminAuthService.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import com.example.demo.auth.JwtProperties;
|
||||||
|
import com.example.demo.auth.JwtService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AdminAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
|
||||||
|
public AdminAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LoginRequest { public String username; public String phone; public String password; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> admin; }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
String keyTmp = null;
|
||||||
|
String valTmp = null;
|
||||||
|
if (req.username != null && !req.username.isBlank()) { keyTmp = "username"; valTmp = req.username.trim(); }
|
||||||
|
else if (req.phone != null && !req.phone.isBlank()) { keyTmp = "phone"; valTmp = req.phone.trim(); }
|
||||||
|
if (keyTmp == null) throw new IllegalArgumentException("用户名或手机号不能为空");
|
||||||
|
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
final String loginKey = keyTmp;
|
||||||
|
final String loginVal = valTmp;
|
||||||
|
|
||||||
|
Map<String,Object> row = jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement("SELECT id, username, phone, password_hash, status FROM admins WHERE "+loginKey+"=? LIMIT 1");
|
||||||
|
ps.setString(1, loginVal);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("username", rs.getString(2));
|
||||||
|
m.put("phone", rs.getString(3));
|
||||||
|
m.put("password_hash", rs.getString(4));
|
||||||
|
m.put("status", rs.getInt(5));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (row == null) throw new IllegalArgumentException("管理员不存在");
|
||||||
|
int status = ((Number)row.get("status")).intValue();
|
||||||
|
if (status != 1) throw new IllegalArgumentException("管理员未启用");
|
||||||
|
String hash = (String) row.get("password_hash");
|
||||||
|
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
|
||||||
|
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
|
||||||
|
if (!ok) throw new IllegalArgumentException("密码错误");
|
||||||
|
|
||||||
|
Long adminId = ((Number)row.get("id")).longValue();
|
||||||
|
String username = (String) row.get("username");
|
||||||
|
|
||||||
|
String token = jwtService.signAdminToken(adminId, username);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
Map<String,Object> admin = new HashMap<>();
|
||||||
|
admin.put("adminId", adminId);
|
||||||
|
admin.put("username", username);
|
||||||
|
admin.put("phone", row.get("phone"));
|
||||||
|
out.admin = admin;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/consults")
|
||||||
|
public class AdminConsultController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public AdminConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT c.id,c.user_id AS userId,c.shop_id AS shopId,s.name AS shopName,c.message,c.status,c.created_at," +
|
||||||
|
"cr.content AS replyContent, cr.created_at AS replyAt " +
|
||||||
|
"FROM consults c JOIN shops s ON s.id=c.shop_id LEFT JOIN consult_replies cr ON cr.consult_id=c.id WHERE 1=1");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (shopId != null) { sql.append(" AND c.shop_id=?"); ps.add(shopId); }
|
||||||
|
if (status != null && !status.isBlank()) { sql.append(" AND c.status=?"); ps.add(status); }
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
sql.append(" AND (c.topic LIKE ? OR c.message LIKE ?)"); String like = "%"+kw.trim()+"%"; ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY c.id DESC LIMIT ").append(offset).append(", ").append(size);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
m.put("replyContent", rs.getString("replyContent"));
|
||||||
|
m.put("replyAt", rs.getTimestamp("replyAt"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reply")
|
||||||
|
public ResponseEntity<?> reply(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
String content = body == null ? null : String.valueOf(body.get("content"));
|
||||||
|
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","content required"));
|
||||||
|
// 单条只允许一条回复:唯一索引兜底,这里先查避免 500
|
||||||
|
Integer exists = jdbcTemplate.query("SELECT 1 FROM consult_replies WHERE consult_id=? LIMIT 1", ps -> ps.setLong(1, id), rs -> rs.next() ? 1 : 0);
|
||||||
|
if (Objects.equals(exists, 1)) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("message", "该咨询已被回复"));
|
||||||
|
}
|
||||||
|
Long uid = (userId != null ? userId : (adminId != null ? adminId : defaults.getUserId()));
|
||||||
|
jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, uid, content);
|
||||||
|
// 自动判定为已解决
|
||||||
|
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("status", "resolved"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/resolve")
|
||||||
|
public ResponseEntity<?> resolve(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// New: Get full history of a user's consults with replies for admin view
|
||||||
|
@GetMapping("/users/{userId}/history")
|
||||||
|
public ResponseEntity<?> userHistory(@PathVariable("userId") Long userId,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId) {
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT id, topic, message, status, created_at FROM consults WHERE user_id=?");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
ps.add(userId);
|
||||||
|
if (shopId != null) { sql.append(" AND shop_id=?"); ps.add(shopId); }
|
||||||
|
sql.append(" ORDER BY id DESC");
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
Long cid = rs.getLong("id");
|
||||||
|
m.put("id", cid);
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
List<Map<String,Object>> replies = jdbcTemplate.query(
|
||||||
|
"SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC",
|
||||||
|
(rs2, j) -> {
|
||||||
|
Map<String,Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", rs2.getLong("id"));
|
||||||
|
r.put("userId", rs2.getLong("userId"));
|
||||||
|
r.put("content", rs2.getString("content"));
|
||||||
|
r.put("createdAt", rs2.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}, cid);
|
||||||
|
m.put("replies", replies);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.product.entity.ProductCategory;
|
||||||
|
import com.example.demo.product.entity.ProductUnit;
|
||||||
|
import com.example.demo.product.repo.CategoryRepository;
|
||||||
|
import com.example.demo.product.repo.UnitRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/dicts")
|
||||||
|
public class AdminDictController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final UnitRepository unitRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public AdminDictController(JdbcTemplate jdbcTemplate, UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.unitRepository = unitRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员校验已由拦截器基于 admins 表统一处理
|
||||||
|
|
||||||
|
// ===== Units =====
|
||||||
|
@PostMapping("/units")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> createUnit(@RequestBody Map<String,Object> body) {
|
||||||
|
String name = body == null ? null : (String) body.get("name");
|
||||||
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
|
Long sid = defaults.getDictShopId();
|
||||||
|
if (unitRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
ProductUnit u = new ProductUnit();
|
||||||
|
u.setShopId(sid);
|
||||||
|
u.setUserId(defaults.getUserId());
|
||||||
|
u.setName(name.trim());
|
||||||
|
u.setCreatedAt(now);
|
||||||
|
u.setUpdatedAt(now);
|
||||||
|
unitRepository.save(u);
|
||||||
|
return ResponseEntity.ok(Map.of("id", u.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/units/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> updateUnit(@PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
||||||
|
String name = body == null ? null : (String) body.get("name");
|
||||||
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
|
ProductUnit u = unitRepository.findById(id).orElse(null);
|
||||||
|
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
|
if (!u.getName().equals(name.trim()) && unitRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
|
u.setUserId(defaults.getUserId());
|
||||||
|
u.setName(name.trim());
|
||||||
|
u.setUpdatedAt(LocalDateTime.now());
|
||||||
|
unitRepository.save(u);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/units/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> deleteUnit(@PathVariable("id") Long id) {
|
||||||
|
ProductUnit u = unitRepository.findById(id).orElse(null);
|
||||||
|
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
|
// 按新方案:移除对 products.unit_id 的引用校验(该字段已移除)
|
||||||
|
unitRepository.deleteById(id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Categories =====
|
||||||
|
@PostMapping("/categories")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> createCategory(@RequestBody Map<String,Object> body) {
|
||||||
|
String name = body == null ? null : (String) body.get("name");
|
||||||
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
|
Long sid = defaults.getDictShopId();
|
||||||
|
if (categoryRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
ProductCategory c = new ProductCategory();
|
||||||
|
c.setShopId(sid);
|
||||||
|
c.setUserId(defaults.getUserId());
|
||||||
|
c.setName(name.trim());
|
||||||
|
c.setSortOrder(0);
|
||||||
|
c.setCreatedAt(now);
|
||||||
|
c.setUpdatedAt(now);
|
||||||
|
categoryRepository.save(c);
|
||||||
|
return ResponseEntity.ok(Map.of("id", c.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/categories/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> updateCategory(@PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
|
||||||
|
String name = body == null ? null : (String) body.get("name");
|
||||||
|
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
|
||||||
|
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
||||||
|
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
|
if (!c.getName().equals(name.trim()) && categoryRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
|
||||||
|
c.setUserId(defaults.getUserId());
|
||||||
|
c.setName(name.trim());
|
||||||
|
c.setUpdatedAt(LocalDateTime.now());
|
||||||
|
categoryRepository.save(c);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/categories/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> deleteCategory(@PathVariable("id") Long id) {
|
||||||
|
ProductCategory c = categoryRepository.findById(id).orElse(null);
|
||||||
|
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
|
||||||
|
// 平台管理员二次确认可在拦截器或前端完成;此处执行软删级联
|
||||||
|
// 1) 软删分类
|
||||||
|
jdbcTemplate.update("UPDATE product_categories SET deleted_at=NOW(), updated_at=NOW() WHERE id=? AND deleted_at IS NULL", id);
|
||||||
|
// 2) 软删分类下模板(使用 deleted_at 统一标记)
|
||||||
|
jdbcTemplate.update("UPDATE part_templates SET deleted_at=NOW(), updated_at=NOW() WHERE category_id=? AND (deleted_at IS NULL)", id);
|
||||||
|
// 3) 软删该分类下的所有商品:包括通过模板创建的与直接挂分类的
|
||||||
|
jdbcTemplate.update("UPDATE products SET deleted_at=NOW(), updated_at=NOW() WHERE (category_id=? OR template_id IN (SELECT id FROM part_templates WHERE category_id=?)) AND deleted_at IS NULL", id, id);
|
||||||
|
// 4) 软删该分类下的所有配件提交:包含直接指向分类的与指向该分类下模板的
|
||||||
|
jdbcTemplate.update("UPDATE part_submissions SET deleted_at=NOW(), updated_at=NOW() WHERE (category_id=? OR template_id IN (SELECT id FROM part_templates WHERE category_id=?)) AND deleted_at IS NULL", id, id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/notices")
|
||||||
|
public class AdminNoticeController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminNoticeController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int limit = Math.max(1, size);
|
||||||
|
int offset = Math.max(0, page - 1) * limit;
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT id,title,content,tag,is_pinned AS pinned,starts_at,ends_at,status,created_at,updated_at FROM notices WHERE deleted_at IS NULL");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
sql.append(" AND status=?");
|
||||||
|
ps.add(status.trim());
|
||||||
|
}
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
sql.append(" AND (title LIKE ? OR content LIKE ?)");
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY is_pinned DESC, created_at DESC LIMIT ? OFFSET ?");
|
||||||
|
ps.add(limit);
|
||||||
|
ps.add(offset);
|
||||||
|
|
||||||
|
List<Map<String, Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("title", rs.getString("title"));
|
||||||
|
m.put("content", rs.getString("content"));
|
||||||
|
m.put("tag", rs.getString("tag"));
|
||||||
|
m.put("pinned", rs.getBoolean("pinned"));
|
||||||
|
m.put("startsAt", rs.getTimestamp("starts_at"));
|
||||||
|
m.put("endsAt", rs.getTimestamp("ends_at"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
m.put("updatedAt", rs.getTimestamp("updated_at"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody Map<String, Object> body) {
|
||||||
|
String title = optString(body.get("title"));
|
||||||
|
String content = optString(body.get("content"));
|
||||||
|
if (title == null || title.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "title required"));
|
||||||
|
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message", "content required"));
|
||||||
|
|
||||||
|
String tag = optString(body.get("tag"));
|
||||||
|
Boolean pinned = optBoolean(body.get("pinned"));
|
||||||
|
String status = sanitizeStatus(optString(body.get("status"))); // draft|published|offline
|
||||||
|
Timestamp startsAt = parseDateTime(optString(body.get("startsAt")));
|
||||||
|
Timestamp endsAt = parseDateTime(optString(body.get("endsAt")));
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO notices (title,content,tag,is_pinned,starts_at,ends_at,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,NOW(),NOW())",
|
||||||
|
title, content, tag, (pinned != null && pinned) ? 1 : 0, startsAt, endsAt, (status == null ? "draft" : status)
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id, @RequestBody Map<String, Object> body) {
|
||||||
|
List<String> sets = new ArrayList<>();
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
|
||||||
|
String title = optString(body.get("title"));
|
||||||
|
String content = optString(body.get("content"));
|
||||||
|
String tag = optString(body.get("tag"));
|
||||||
|
Boolean pinned = optBoolean(body.get("pinned"));
|
||||||
|
String status = sanitizeStatus(optString(body.get("status")));
|
||||||
|
Timestamp startsAt = parseDateTime(optString(body.get("startsAt")));
|
||||||
|
Timestamp endsAt = parseDateTime(optString(body.get("endsAt")));
|
||||||
|
|
||||||
|
if (title != null) { sets.add("title=?"); ps.add(title); }
|
||||||
|
if (content != null) { sets.add("content=?"); ps.add(content); }
|
||||||
|
if (tag != null) { sets.add("tag=?"); ps.add(tag); }
|
||||||
|
if (pinned != null) { sets.add("is_pinned=?"); ps.add(pinned ? 1 : 0); }
|
||||||
|
if (status != null) { sets.add("status=?"); ps.add(status); }
|
||||||
|
if (body.containsKey("startsAt")) { sets.add("starts_at=?"); ps.add(startsAt); }
|
||||||
|
if (body.containsKey("endsAt")) { sets.add("ends_at=?"); ps.add(endsAt); }
|
||||||
|
|
||||||
|
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
||||||
|
String sql = "UPDATE notices SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/publish")
|
||||||
|
public ResponseEntity<?> publish(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE notices SET status='published', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/offline")
|
||||||
|
public ResponseEntity<?> offline(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE notices SET status='offline', updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
|
||||||
|
private static Boolean optBoolean(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
String s = String.valueOf(v);
|
||||||
|
return ("1".equals(s) || "true".equalsIgnoreCase(s));
|
||||||
|
}
|
||||||
|
private static Timestamp parseDateTime(String s) {
|
||||||
|
if (s == null || s.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
// ISO-8601 with zone
|
||||||
|
OffsetDateTime odt = OffsetDateTime.parse(s);
|
||||||
|
return Timestamp.from(odt.toInstant());
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
try {
|
||||||
|
// ISO-8601 local date-time
|
||||||
|
LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ISO_DATE_TIME);
|
||||||
|
return Timestamp.valueOf(ldt);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
try {
|
||||||
|
// Fallback: yyyy-MM-dd HH:mm:ss
|
||||||
|
LocalDateTime ldt = LocalDateTime.parse(s, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
return Timestamp.valueOf(ldt);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static String sanitizeStatus(String s) {
|
||||||
|
if (s == null) return null;
|
||||||
|
String v = s.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if ("draft".equals(v) || "published".equals(v) || "offline".equals(v)) return v;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/parts")
|
||||||
|
public class AdminPartController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminPartController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT p.id,p.user_id AS userId,p.shop_id AS shopId,s.name AS shopName,p.name,p.brand,p.model,p.spec,p.is_blacklisted, " +
|
||||||
|
"p.template_id AS templateId, t.name AS templateName, p.attributes_json AS attributesJson " +
|
||||||
|
"FROM products p JOIN shops s ON s.id=p.shop_id LEFT JOIN part_templates t ON t.id=p.template_id WHERE p.deleted_at IS NULL");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (shopId != null) { sql.append(" AND p.shop_id=?"); ps.add(shopId); }
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
sql.append(" AND (p.name LIKE ? OR p.brand LIKE ? OR p.model LIKE ? OR p.spec LIKE ?)");
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like); ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
// 为兼容已有前端查询参数,保留 status 入参但不再基于黑名单做过滤(忽略非数字值)
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
try {
|
||||||
|
Integer.parseInt(status); // no-op, keep compatibility
|
||||||
|
} catch (NumberFormatException ignore) { }
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY p.id DESC LIMIT ").append(offset).append(", ").append(size);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("brand", rs.getString("brand"));
|
||||||
|
m.put("model", rs.getString("model"));
|
||||||
|
m.put("spec", rs.getString("spec"));
|
||||||
|
Object tid = rs.getObject("templateId");
|
||||||
|
if (tid != null) m.put("templateId", tid);
|
||||||
|
m.put("templateName", rs.getString("templateName"));
|
||||||
|
m.put("attributesJson", rs.getString("attributesJson"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
// 附加每个商品的图片列表
|
||||||
|
if (!list.isEmpty()) {
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
for (Map<String,Object> m : list) {
|
||||||
|
Object v = m.get("id");
|
||||||
|
if (v instanceof Number) ids.add(((Number) v).longValue());
|
||||||
|
}
|
||||||
|
if (!ids.isEmpty()) {
|
||||||
|
StringBuilder in = new StringBuilder();
|
||||||
|
for (int i = 0; i < ids.size(); i++) { if (i>0) in.append(','); in.append('?'); }
|
||||||
|
List<Map<String,Object>> imgRows = jdbcTemplate.query(
|
||||||
|
"SELECT product_id AS productId, url FROM product_images WHERE product_id IN (" + in + ") ORDER BY sort_order ASC, id ASC",
|
||||||
|
ids.toArray(),
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("productId", rs.getLong("productId"));
|
||||||
|
m.put("url", rs.getString("url"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Map<Long, List<String>> map = new HashMap<>();
|
||||||
|
for (Map<String,Object> r : imgRows) {
|
||||||
|
Long pid = ((Number) r.get("productId")).longValue();
|
||||||
|
String url = String.valueOf(r.get("url"));
|
||||||
|
map.computeIfAbsent(pid, k -> new ArrayList<>()).add(url);
|
||||||
|
}
|
||||||
|
for (Map<String,Object> m : list) {
|
||||||
|
Long pid = ((Number) m.get("id")).longValue();
|
||||||
|
List<String> imgs = map.get(pid);
|
||||||
|
m.put("images", imgs == null ? Collections.emptyList() : imgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
// 校验商品是否存在,并取出 shopId
|
||||||
|
List<Map<String, Object>> prodRows = jdbcTemplate.query(
|
||||||
|
"SELECT id, shop_id FROM products WHERE id=?",
|
||||||
|
new Object[]{id},
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("shopId", rs.getLong(2));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (prodRows.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
Long shopId = ((Number) prodRows.get(0).get("shopId")).longValue();
|
||||||
|
|
||||||
|
String brand = optString(body.get("brand"));
|
||||||
|
String model = optString(body.get("model"));
|
||||||
|
String spec = optString(body.get("spec"));
|
||||||
|
List<String> images = optStringList(body.get("images"));
|
||||||
|
|
||||||
|
// 更新 products 基本字段(可选)
|
||||||
|
List<String> sets = new ArrayList<>();
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (brand != null) { sets.add("brand=?"); ps.add(brand); }
|
||||||
|
if (model != null) { sets.add("model=?"); ps.add(model); }
|
||||||
|
if (spec != null) { sets.add("spec=?"); ps.add(spec); }
|
||||||
|
if (!sets.isEmpty()) {
|
||||||
|
String sql = "UPDATE products SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 覆盖式更新图片(提供 images 列表时)
|
||||||
|
if (images != null) {
|
||||||
|
if (adminId == null) throw new IllegalArgumentException("X-Admin-Id 不能为空");
|
||||||
|
jdbcTemplate.update("DELETE FROM product_images WHERE product_id= ?", id);
|
||||||
|
if (!images.isEmpty()) {
|
||||||
|
List<Object[]> batch = new ArrayList<>();
|
||||||
|
int sort = 0;
|
||||||
|
for (String url : images) {
|
||||||
|
if (url == null || url.isBlank()) continue;
|
||||||
|
// 管理员为平台操作,不属于店铺用户;使用默认用户ID(1)满足非空外键
|
||||||
|
batch.add(new Object[]{shopId, 1L, id, url.trim(), sort++});
|
||||||
|
}
|
||||||
|
if (!batch.isEmpty()) {
|
||||||
|
jdbcTemplate.batchUpdate(
|
||||||
|
"INSERT INTO product_images (shop_id, user_id, product_id, url, sort_order, created_at) VALUES (?,?,?,?,?,NOW())",
|
||||||
|
batch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optString(Object v) { return v == null ? null : String.valueOf(v); }
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<String> optStringList(Object v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v instanceof List) {
|
||||||
|
List<?> src = (List<?>) v;
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (Object o : src) { if (o != null) out.add(String.valueOf(o)); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 兼容逗号分隔字符串
|
||||||
|
String s = String.valueOf(v);
|
||||||
|
if (s.isBlank()) return new ArrayList<>();
|
||||||
|
String[] arr = s.split(",");
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
for (String x : arr) { String t = x.trim(); if (!t.isEmpty()) out.add(t); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/users")
|
||||||
|
public class AdminUserController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminUserController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT u.id,u.name,u.phone,u.role,u.status,u.is_owner AS isOwner,u.shop_id AS shopId,s.name AS shopName " +
|
||||||
|
"FROM users u JOIN shops s ON s.id=u.shop_id WHERE 1=1");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (shopId != null) { sql.append(" AND u.shop_id=?"); ps.add(shopId); }
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
sql.append(" AND (u.name LIKE ? OR u.phone LIKE ? OR u.role LIKE ?)");
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
||||||
|
ps.add(size); ps.add(offset);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
m.put("role", rs.getString("role"));
|
||||||
|
m.put("status", rs.getInt("status"));
|
||||||
|
m.put("isOwner", rs.getBoolean("isOwner"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("list", list);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
// 仅允许更新以下字段
|
||||||
|
String name = optString(body.get("name"));
|
||||||
|
String phone = optString(body.get("phone"));
|
||||||
|
String role = optString(body.get("role"));
|
||||||
|
Integer status = optInteger(body.get("status"));
|
||||||
|
Boolean isOwner = optBoolean(body.get("isOwner"));
|
||||||
|
|
||||||
|
List<String> sets = new ArrayList<>();
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (name != null) { sets.add("name=?"); ps.add(name); }
|
||||||
|
if (phone != null) { sets.add("phone=?"); ps.add(phone); }
|
||||||
|
if (role != null) { sets.add("role=?"); ps.add(role); }
|
||||||
|
if (status != null) { sets.add("status=?"); ps.add(status); }
|
||||||
|
if (isOwner != null) { sets.add("is_owner=?"); ps.add(isOwner ? 1 : 0); }
|
||||||
|
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
||||||
|
String sql = "UPDATE users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/blacklist")
|
||||||
|
public ResponseEntity<?> blacklist(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE users SET status=0, updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}/restore")
|
||||||
|
public ResponseEntity<?> restore(@PathVariable("id") Long id) {
|
||||||
|
jdbcTemplate.update("UPDATE users SET status=1, updated_at=NOW() WHERE id=?", id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
|
||||||
|
private static Integer optInteger(Object v) { try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } }
|
||||||
|
private static Boolean optBoolean(Object v) { if (v==null) return null; String s=String.valueOf(v); return ("1".equals(s) || "true".equalsIgnoreCase(s)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/vips")
|
||||||
|
public class AdminVipController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminVipController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "phone", required = false) String phone,
|
||||||
|
@RequestParam(name = "status", required = false) Integer status,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt,v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " +
|
||||||
|
"FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE 1=1");
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (shopId != null) { sql.append(" AND v.shop_id=?"); ps.add(shopId); }
|
||||||
|
if (phone != null && !phone.isBlank()) { sql.append(" AND u.phone LIKE ?"); ps.add("%"+phone.trim()+"%"); }
|
||||||
|
if (status != null) { sql.append(" AND v.status = ?"); ps.add(status); }
|
||||||
|
sql.append(" ORDER BY v.id DESC LIMIT ").append(offset).append(", ").append(size);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("isVip", rs.getInt("isVip"));
|
||||||
|
m.put("status", rs.getInt("status"));
|
||||||
|
m.put("expireAt", rs.getTimestamp("expireAt"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
return ResponseEntity.ok(Map.of("list", list));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-User-Id") Long userId,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
Long sid = asLong(body.get("shopId"));
|
||||||
|
if (sid == null) return ResponseEntity.badRequest().body(Map.of("message","shopId required"));
|
||||||
|
Long uid = asLong(body.get("userId")); if (uid == null) return ResponseEntity.badRequest().body(Map.of("message","userId required"));
|
||||||
|
Integer isVip = asIntOr(body.get("isVip"), 1);
|
||||||
|
java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt"));
|
||||||
|
String remark = str(body.get("remark"));
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_users (shop_id,user_id,is_vip,status,expire_at,remark,created_at,updated_at) VALUES (?,?,?,?,?, ?,NOW(),NOW())",
|
||||||
|
sid, uid, isVip, 0, expireAt, remark);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
List<String> sets = new ArrayList<>(); List<Object> ps = new ArrayList<>();
|
||||||
|
Integer isVip = asInt(body.get("isVip")); if (isVip != null) { sets.add("is_vip=?"); ps.add(isVip); }
|
||||||
|
Integer status = asInt(body.get("status")); if (status != null) { sets.add("status=?"); ps.add(status); }
|
||||||
|
java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt")); if (expireAt != null) { sets.add("expire_at=?"); ps.add(expireAt); }
|
||||||
|
String remark = str(body.get("remark")); if (remark != null) { sets.add("remark=?"); ps.add(remark); }
|
||||||
|
if (sets.isEmpty()) return ResponseEntity.ok().build();
|
||||||
|
String sql = "UPDATE vip_users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
|
||||||
|
ps.add(id);
|
||||||
|
jdbcTemplate.update(sql, ps.toArray());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/user/{userId}")
|
||||||
|
public ResponseEntity<?> getByUser(@PathVariable("userId") Long userId) {
|
||||||
|
String sql = "SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt, v.created_at AS createdAt, v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " +
|
||||||
|
"FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE v.user_id=? ORDER BY v.id DESC LIMIT 1";
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.query(sql, (rs) -> {
|
||||||
|
java.util.List<java.util.Map<String,Object>> rows = new java.util.ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
java.util.Map<String,Object> m = new java.util.LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("isVip", rs.getInt("isVip"));
|
||||||
|
m.put("status", rs.getInt("status"));
|
||||||
|
m.put("expireAt", rs.getTimestamp("expireAt"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("shopName", rs.getString("shopName"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
rows.add(m);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, userId);
|
||||||
|
if (list == null || list.isEmpty()) return ResponseEntity.ok(java.util.Map.of());
|
||||||
|
return ResponseEntity.ok(list.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object v){ return v==null?null:String.valueOf(v); }
|
||||||
|
private static Integer asInt(Object v){ try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
|
||||||
|
private static Integer asIntOr(Object v, int d){ Integer i = asInt(v); return i==null?d:i; }
|
||||||
|
private static Long asLong(Object v){ try { return v==null?null:Long.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
|
||||||
|
private static java.sql.Timestamp asTimestamp(Object v){
|
||||||
|
if (v == null) return null;
|
||||||
|
try { return java.sql.Timestamp.valueOf(String.valueOf(v).replace('T',' ').replace('Z',' ')); } catch(Exception e){ return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/vip/price")
|
||||||
|
public class AdminVipPriceController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminVipPriceController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) {
|
||||||
|
Double price = 0d;
|
||||||
|
try {
|
||||||
|
price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return ResponseEntity.ok(Map.of("price", price));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ResponseEntity<?> setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
Double price = asDouble(body.get("price"));
|
||||||
|
if (price == null) return ResponseEntity.badRequest().body(Map.of("message", "price required"));
|
||||||
|
// 单记录表:清空后插入
|
||||||
|
jdbcTemplate.update("DELETE FROM vip_price");
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_price (price) VALUES (?)", price);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double asDouble(Object v) {
|
||||||
|
try { return v == null ? null : Double.valueOf(String.valueOf(v)); } catch (Exception e) { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/vip/system")
|
||||||
|
public class AdminVipSystemController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AdminVipSystemController(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/price")
|
||||||
|
public ResponseEntity<?> getPrice(@RequestHeader(name = "X-Admin-Id") Long adminId) {
|
||||||
|
Double price = jdbcTemplate.query("SELECT price FROM vip_price LIMIT 1", rs -> rs.next() ? rs.getDouble(1) : 0d);
|
||||||
|
return ResponseEntity.ok(Map.of("price", price));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/price")
|
||||||
|
public ResponseEntity<?> setPrice(@RequestHeader(name = "X-Admin-Id") Long adminId, @RequestBody Map<String,Object> body) {
|
||||||
|
Double price = asDouble(body.get("price"));
|
||||||
|
if (price == null) return ResponseEntity.badRequest().body(Map.of("message","price required"));
|
||||||
|
jdbcTemplate.update("DELETE FROM vip_price");
|
||||||
|
jdbcTemplate.update("INSERT INTO vip_price(price) VALUES(?)", price);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recharges")
|
||||||
|
public ResponseEntity<?> listRecharges(@RequestHeader(name = "X-Admin-Id") Long adminId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, page - 1) * Math.max(1, size);
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT r.id,r.shop_id AS shopId,s.name AS shopName,r.user_id AS userId,u.name,u.phone,r.price,r.duration_days AS durationDays,r.expire_from AS expireFrom,r.expire_to AS expireTo,r.channel,r.created_at AS createdAt FROM vip_recharges r LEFT JOIN users u ON u.id=r.user_id LEFT JOIN shops s ON s.id=r.shop_id WHERE 1=1");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (u.phone LIKE ? OR u.name LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
sql.append(" ORDER BY r.id DESC LIMIT ").append(size).append(" OFFSET ").append(offset);
|
||||||
|
List<Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
Map<String,Object> resp = new HashMap<>();
|
||||||
|
resp.put("list", list);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double asDouble(Object v) { try { return v==null?null:Double.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.example.demo.admin;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/normal-admin")
|
||||||
|
public class NormalAdminApprovalController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public NormalAdminApprovalController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||||
|
|
||||||
|
@GetMapping("/applications")
|
||||||
|
public ResponseEntity<?> list(@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
int offset = Math.max(0, (page - 1) * size);
|
||||||
|
String base = "SELECT a.id,a.shop_id AS shopId,a.user_id AS userId,u.name,u.email,u.phone,a.remark,a.created_at AS createdAt " +
|
||||||
|
"FROM normal_admin_audits a JOIN users u ON u.id=a.user_id WHERE a.action='apply' AND NOT EXISTS (" +
|
||||||
|
"SELECT 1 FROM normal_admin_audits x WHERE x.user_id=a.user_id AND x.created_at>a.created_at AND x.action IN ('approve','reject'))";
|
||||||
|
List<Object> ps = new ArrayList<>();
|
||||||
|
if (kw != null && !kw.isBlank()) {
|
||||||
|
base += " AND (u.name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)";
|
||||||
|
String like = "%" + kw.trim() + "%";
|
||||||
|
ps.add(like); ps.add(like); ps.add(like);
|
||||||
|
}
|
||||||
|
String pageSql = base + " ORDER BY a.created_at DESC LIMIT ? OFFSET ?";
|
||||||
|
ps.add(size); ps.add(offset);
|
||||||
|
List<Map<String,Object>> list = jdbc.query(pageSql, ps.toArray(), (rs, i) -> {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("name", rs.getString("name"));
|
||||||
|
m.put("email", rs.getString("email"));
|
||||||
|
m.put("phone", rs.getString("phone"));
|
||||||
|
m.put("remark", rs.getString("remark"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
// 简化:total 暂以当前页大小代替(可扩展 count)
|
||||||
|
return ResponseEntity.ok(Map.of("list", list, "total", list.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/applications/{userId}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
// 记录 previous_role
|
||||||
|
final String prev = jdbc.query("SELECT role FROM users WHERE id=? LIMIT 1", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
if (prev == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found"));
|
||||||
|
jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, "normal_admin"); ps.setLong(2, userId); });
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'approve', ?, ?, ?, ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, body != null ? Objects.toString(body.get("remark"), null) : null);
|
||||||
|
if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId);
|
||||||
|
ps.setString(3, prev); ps.setString(4, "normal_admin"); ps.setLong(5, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/applications/{userId}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody Map<String,Object> body) {
|
||||||
|
String remark = body == null ? null : Objects.toString(body.get("remark"), null);
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'reject', ?, ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, remark); if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId); ps.setLong(3, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{userId}/revoke")
|
||||||
|
public ResponseEntity<?> revoke(@PathVariable("userId") long userId,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
// 找到最近一次 approve 的 previous_role
|
||||||
|
final String prev = jdbc.query("SELECT previous_role FROM normal_admin_audits WHERE user_id=? AND action='approve' ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
String finalPrev = prev;
|
||||||
|
if (finalPrev == null || finalPrev.isBlank()) {
|
||||||
|
// fallback:根据是否店主回退
|
||||||
|
Boolean owner = jdbc.query("SELECT is_owner FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getBoolean(1) : false);
|
||||||
|
finalPrev = (owner != null && owner) ? "owner" : "staff";
|
||||||
|
}
|
||||||
|
String prevRoleForAudit = finalPrev;
|
||||||
|
jdbc.update("UPDATE users SET role=? WHERE id=?", ps -> { ps.setString(1, prevRoleForAudit); ps.setLong(2, userId); });
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) " +
|
||||||
|
"SELECT u.shop_id, u.id, 'revoke', ?, ?, 'normal_admin', ?, NOW() FROM users u WHERE u.id=?",
|
||||||
|
ps -> { ps.setString(1, body == null ? null : Objects.toString(body.get("remark"), null));
|
||||||
|
if (adminId == null) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, adminId);
|
||||||
|
ps.setString(3, prevRoleForAudit); ps.setLong(4, userId);} );
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/attachments")
|
||||||
|
public class AttachmentController {
|
||||||
|
|
||||||
|
private final AttachmentPlaceholderProperties placeholderProperties;
|
||||||
|
private final AttachmentUrlValidator urlValidator;
|
||||||
|
private final AttachmentStorageService storageService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties,
|
||||||
|
AttachmentUrlValidator urlValidator,
|
||||||
|
AttachmentStorageService storageService,
|
||||||
|
JdbcTemplate jdbcTemplate) {
|
||||||
|
this.placeholderProperties = placeholderProperties;
|
||||||
|
this.urlValidator = urlValidator;
|
||||||
|
this.storageService = storageService;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<Map<String, Object>> upload(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "ownerType", required = false) String ownerType,
|
||||||
|
@RequestParam(value = "ownerId", required = false) Long ownerId) throws IOException {
|
||||||
|
AttachmentStorageService.StoredObject so = storageService.store(file);
|
||||||
|
|
||||||
|
String ot = StringUtils.hasText(ownerType) ? ownerType.trim() : "global";
|
||||||
|
Long oid = ownerId == null ? 0L : ownerId;
|
||||||
|
|
||||||
|
// 写入 attachments 表
|
||||||
|
String metaJson = buildMetaJson(so.relativePath(), so.contentType(), so.size());
|
||||||
|
try {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO attachments (shop_id, user_id, owner_type, owner_id, url, hash, meta, created_at) VALUES (?,?,?,?,?,?,?,NOW())",
|
||||||
|
shopId, userId, ot, oid, "/api/attachments/content/" + so.sha256(), so.sha256(), metaJson
|
||||||
|
);
|
||||||
|
} catch (DuplicateKeyException ignore) {
|
||||||
|
// 已存在相同hash记录,忽略插入以实现幂等
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("url", "/api/attachments/content/" + so.sha256());
|
||||||
|
body.put("contentType", so.contentType());
|
||||||
|
body.put("contentLength", so.size());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<Map<String, Object>> validateUrlJson(@RequestBody Map<String, Object> body) {
|
||||||
|
String url = body == null ? null : String.valueOf(body.get("url"));
|
||||||
|
AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url);
|
||||||
|
Map<String, Object> resp = new HashMap<>();
|
||||||
|
resp.put("url", vr.url());
|
||||||
|
resp.put("contentType", vr.contentType());
|
||||||
|
if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength());
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/validate-url", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
|
public ResponseEntity<Map<String, Object>> validateUrlForm(@RequestParam("url") String url) {
|
||||||
|
AttachmentUrlValidator.ValidationResult vr = urlValidator.validate(url);
|
||||||
|
Map<String, Object> resp = new HashMap<>();
|
||||||
|
resp.put("url", vr.url());
|
||||||
|
resp.put("contentType", vr.contentType());
|
||||||
|
if (vr.contentLength() != null) resp.put("contentLength", vr.contentLength());
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/placeholder")
|
||||||
|
public ResponseEntity<Resource> placeholder() throws IOException {
|
||||||
|
String imagePath = placeholderProperties.getImagePath();
|
||||||
|
if (!StringUtils.hasText(imagePath)) {
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
Path path = Path.of(imagePath);
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
return ResponseEntity.status(404).build();
|
||||||
|
}
|
||||||
|
Resource resource = new FileSystemResource(path);
|
||||||
|
String contentType = null;
|
||||||
|
try {
|
||||||
|
contentType = Files.probeContentType(path);
|
||||||
|
} catch (IOException ignore) {
|
||||||
|
contentType = null;
|
||||||
|
}
|
||||||
|
MediaType mediaType;
|
||||||
|
try {
|
||||||
|
if (contentType == null || contentType.isBlank()) {
|
||||||
|
mediaType = MediaType.IMAGE_PNG;
|
||||||
|
} else {
|
||||||
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder")
|
||||||
|
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
|
||||||
|
.contentType(mediaType)
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/content/{sha256}")
|
||||||
|
public ResponseEntity<Resource> contentByHash(@PathVariable("sha256") String sha256) throws IOException {
|
||||||
|
if (!StringUtils.hasText(sha256)) return ResponseEntity.badRequest().build();
|
||||||
|
// 从数据库读取 meta.path 获取相对路径
|
||||||
|
String relativePath = null;
|
||||||
|
try {
|
||||||
|
String meta = jdbcTemplate.query("SELECT meta FROM attachments WHERE hash=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> ps.setString(1, sha256),
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
relativePath = extractPathFromMetaJson(meta);
|
||||||
|
if (!StringUtils.hasText(relativePath)) {
|
||||||
|
relativePath = extractPathFromMeta(meta);
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) { relativePath = null; }
|
||||||
|
|
||||||
|
Path found = null;
|
||||||
|
if (StringUtils.hasText(relativePath)) {
|
||||||
|
try { found = storageService.resolveAbsolutePath(relativePath); } catch (Exception ignore) { found = null; }
|
||||||
|
}
|
||||||
|
if (found == null || !Files.exists(found)) {
|
||||||
|
// 兜底:全目录扫描(少量文件可接受)
|
||||||
|
Path storageRoot = storageService != null ? storageService.getStorageRoot() : Path.of("./data/attachments");
|
||||||
|
if (Files.exists(storageRoot)) {
|
||||||
|
found = findFileByHash(storageRoot, sha256);
|
||||||
|
}
|
||||||
|
if (found == null) return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource resource = new FileSystemResource(found);
|
||||||
|
String contentType = null;
|
||||||
|
try { contentType = Files.probeContentType(found); } catch (IOException ignore) { contentType = null; }
|
||||||
|
MediaType mediaType;
|
||||||
|
try {
|
||||||
|
if (contentType == null || contentType.isBlank()) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
} else {
|
||||||
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
}
|
||||||
|
} catch (Exception e) { mediaType = MediaType.APPLICATION_OCTET_STREAM; }
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=" + found.getFileName())
|
||||||
|
.cacheControl(CacheControl.maxAge(30, java.util.concurrent.TimeUnit.DAYS).cachePublic())
|
||||||
|
.contentType(mediaType)
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path findFileByHash(Path root, String sha256) throws IOException {
|
||||||
|
try (var stream = Files.walk(root)) {
|
||||||
|
return stream
|
||||||
|
.filter(p -> Files.isRegularFile(p))
|
||||||
|
.filter(p -> {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
return name.equals(sha256) || name.startsWith(sha256 + ".");
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildMetaJson(String relativePath, String contentType, long size) {
|
||||||
|
return "{" +
|
||||||
|
"\"path\":\"" + escapeJson(relativePath) + "\"," +
|
||||||
|
"\"contentType\":\"" + escapeJson(contentType) + "\"," +
|
||||||
|
"\"size\":" + size +
|
||||||
|
"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPathFromMeta(String meta) {
|
||||||
|
if (!StringUtils.hasText(meta)) return null;
|
||||||
|
int i = meta.indexOf("\"path\":\"");
|
||||||
|
if (i < 0) return null;
|
||||||
|
int s = i + 8; // length of "path":"
|
||||||
|
int e = meta.indexOf('"', s);
|
||||||
|
if (e < 0) return null;
|
||||||
|
String val = meta.substring(s, e);
|
||||||
|
return val.replace("\\\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPathFromMetaJson(String meta) {
|
||||||
|
if (!StringUtils.hasText(meta)) return null;
|
||||||
|
try {
|
||||||
|
ObjectMapper om = new ObjectMapper();
|
||||||
|
Map<String,Object> m = om.readValue(meta, new TypeReference<Map<String,Object>>(){});
|
||||||
|
Object p = m.get("path");
|
||||||
|
return p == null ? null : String.valueOf(p);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "attachments.placeholder")
|
||||||
|
public class AttachmentPlaceholderProperties {
|
||||||
|
|
||||||
|
private String imagePath;
|
||||||
|
private String urlPath = "/api/attachments/placeholder";
|
||||||
|
|
||||||
|
public String getImagePath() {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setImagePath(String imagePath) {
|
||||||
|
this.imagePath = imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrlPath() {
|
||||||
|
return urlPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrlPath(String urlPath) {
|
||||||
|
this.urlPath = urlPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AttachmentStorageService {
|
||||||
|
|
||||||
|
private final AttachmentUploadProperties props;
|
||||||
|
|
||||||
|
public AttachmentStorageService(AttachmentUploadProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredObject store(MultipartFile file) throws IOException {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("文件为空");
|
||||||
|
}
|
||||||
|
long size = file.getSize();
|
||||||
|
if (size > props.getMaxSizeBytes()) {
|
||||||
|
throw new IllegalArgumentException("文件过大,超过上限" + props.getMaxSizeMb() + "MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = normalizeContentType(file.getContentType());
|
||||||
|
if (!isAllowedContentType(contentType)) {
|
||||||
|
// 尝试根据扩展名推断
|
||||||
|
String guessed = guessContentTypeFromFilename(file.getOriginalFilename());
|
||||||
|
if (!isAllowedContentType(guessed)) {
|
||||||
|
throw new IllegalArgumentException("不支持的文件类型");
|
||||||
|
}
|
||||||
|
contentType = guessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算哈希
|
||||||
|
String sha256 = sha256Hex(file);
|
||||||
|
|
||||||
|
// 生成相对路径:yyyy/MM/dd/<sha256>.<ext>
|
||||||
|
String ext = extensionForContentType(contentType);
|
||||||
|
String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||||
|
Path baseDir = Path.of(props.getStorageDir());
|
||||||
|
Path dir = baseDir.resolve(datePath);
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path target = dir.resolve(sha256 + (ext == null ? "" : ("." + ext)));
|
||||||
|
|
||||||
|
// 保存文件(覆盖策略:相同哈希重复上传时幂等)
|
||||||
|
try (InputStream in = file.getInputStream()) {
|
||||||
|
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
String relativePath = baseDir.toAbsolutePath().normalize().relativize(target.toAbsolutePath().normalize()).toString().replace('\\', '/');
|
||||||
|
return new StoredObject(relativePath, contentType, size, sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path resolveAbsolutePath(String relativePath) {
|
||||||
|
if (!StringUtils.hasText(relativePath)) {
|
||||||
|
throw new IllegalArgumentException("路径无效");
|
||||||
|
}
|
||||||
|
return Path.of(props.getStorageDir()).resolve(relativePath).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getStorageRoot() {
|
||||||
|
return Path.of(props.getStorageDir()).normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return null;
|
||||||
|
int idx = ct.indexOf(';');
|
||||||
|
String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return false;
|
||||||
|
for (String allowed : props.getAllowedContentTypes()) {
|
||||||
|
if (ct.equalsIgnoreCase(allowed)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extensionForContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return null;
|
||||||
|
return switch (ct) {
|
||||||
|
case "image/jpeg" -> "jpg";
|
||||||
|
case "image/png" -> "png";
|
||||||
|
case "image/gif" -> "gif";
|
||||||
|
case "image/webp" -> "webp";
|
||||||
|
case "image/svg+xml" -> "svg";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String guessContentTypeFromFilename(String name) {
|
||||||
|
if (!StringUtils.hasText(name)) return null;
|
||||||
|
String n = name.toLowerCase(Locale.ROOT);
|
||||||
|
if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return MediaType.IMAGE_JPEG_VALUE;
|
||||||
|
if (n.endsWith(".png")) return MediaType.IMAGE_PNG_VALUE;
|
||||||
|
if (n.endsWith(".gif")) return MediaType.IMAGE_GIF_VALUE;
|
||||||
|
if (n.endsWith(".webp")) return "image/webp";
|
||||||
|
if (n.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(MultipartFile file) throws IOException {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
try (InputStream in = file.getInputStream(); DigestInputStream dis = new DigestInputStream(in, md)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while (dis.read(buffer) != -1) { /* drain */ }
|
||||||
|
}
|
||||||
|
byte[] digest = md.digest();
|
||||||
|
return HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 不可用", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoredObject(String relativePath, String contentType, long size, String sha256) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "attachments.upload")
|
||||||
|
public class AttachmentUploadProperties {
|
||||||
|
|
||||||
|
private String storageDir = "./data/attachments";
|
||||||
|
private int maxSizeMb = 5;
|
||||||
|
private List<String> allowedContentTypes = new ArrayList<>(Arrays.asList(
|
||||||
|
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"
|
||||||
|
));
|
||||||
|
|
||||||
|
public String getStorageDir() { return storageDir; }
|
||||||
|
public void setStorageDir(String storageDir) { this.storageDir = storageDir; }
|
||||||
|
|
||||||
|
public int getMaxSizeMb() { return maxSizeMb; }
|
||||||
|
public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; }
|
||||||
|
|
||||||
|
public List<String> getAllowedContentTypes() { return allowedContentTypes; }
|
||||||
|
public void setAllowedContentTypes(List<String> allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; }
|
||||||
|
|
||||||
|
public long getMaxSizeBytes() { return (long) maxSizeMb * 1024L * 1024L; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "attachments.url")
|
||||||
|
public class AttachmentUrlValidationProperties {
|
||||||
|
|
||||||
|
private boolean ssrfProtection = true;
|
||||||
|
private boolean allowPrivateIp = false;
|
||||||
|
private boolean followRedirects = true;
|
||||||
|
private int maxRedirects = 2;
|
||||||
|
private int connectTimeoutMs = 3000;
|
||||||
|
private int readTimeoutMs = 5000;
|
||||||
|
private int maxSizeMb = 5;
|
||||||
|
private List<String> allowlist = new ArrayList<>();
|
||||||
|
private List<String> allowedContentTypes = new ArrayList<>(Arrays.asList(
|
||||||
|
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"
|
||||||
|
));
|
||||||
|
|
||||||
|
public boolean isSsrfProtection() { return ssrfProtection; }
|
||||||
|
public void setSsrfProtection(boolean ssrfProtection) { this.ssrfProtection = ssrfProtection; }
|
||||||
|
|
||||||
|
public boolean isAllowPrivateIp() { return allowPrivateIp; }
|
||||||
|
public void setAllowPrivateIp(boolean allowPrivateIp) { this.allowPrivateIp = allowPrivateIp; }
|
||||||
|
|
||||||
|
public boolean isFollowRedirects() { return followRedirects; }
|
||||||
|
public void setFollowRedirects(boolean followRedirects) { this.followRedirects = followRedirects; }
|
||||||
|
|
||||||
|
public int getMaxRedirects() { return maxRedirects; }
|
||||||
|
public void setMaxRedirects(int maxRedirects) { this.maxRedirects = maxRedirects; }
|
||||||
|
|
||||||
|
public int getConnectTimeoutMs() { return connectTimeoutMs; }
|
||||||
|
public void setConnectTimeoutMs(int connectTimeoutMs) { this.connectTimeoutMs = connectTimeoutMs; }
|
||||||
|
|
||||||
|
public int getReadTimeoutMs() { return readTimeoutMs; }
|
||||||
|
public void setReadTimeoutMs(int readTimeoutMs) { this.readTimeoutMs = readTimeoutMs; }
|
||||||
|
|
||||||
|
public int getMaxSizeMb() { return maxSizeMb; }
|
||||||
|
public void setMaxSizeMb(int maxSizeMb) { this.maxSizeMb = maxSizeMb; }
|
||||||
|
|
||||||
|
public List<String> getAllowlist() { return allowlist; }
|
||||||
|
public void setAllowlist(List<String> allowlist) { this.allowlist = allowlist; }
|
||||||
|
|
||||||
|
public List<String> getAllowedContentTypes() { return allowedContentTypes; }
|
||||||
|
public void setAllowedContentTypes(List<String> allowedContentTypes) { this.allowedContentTypes = allowedContentTypes; }
|
||||||
|
|
||||||
|
public long getMaxSizeBytes() {
|
||||||
|
return (long) maxSizeMb * 1024L * 1024L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package com.example.demo.attachment;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AttachmentUrlValidator {
|
||||||
|
|
||||||
|
private final AttachmentUrlValidationProperties props;
|
||||||
|
|
||||||
|
public AttachmentUrlValidator(AttachmentUrlValidationProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValidationResult validate(String urlString) {
|
||||||
|
if (!StringUtils.hasText(urlString)) {
|
||||||
|
throw new IllegalArgumentException("url不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = new URI(urlString.trim());
|
||||||
|
if (!"http".equalsIgnoreCase(uri.getScheme()) && !"https".equalsIgnoreCase(uri.getScheme())) {
|
||||||
|
throw new IllegalArgumentException("仅支持http/https");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(uri.getHost())) {
|
||||||
|
throw new IllegalArgumentException("URL缺少主机名");
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowlist 校验
|
||||||
|
enforceAllowlist(uri.getHost());
|
||||||
|
|
||||||
|
// SSRF/IP 私网校验
|
||||||
|
if (props.isSsrfProtection()) {
|
||||||
|
enforceIpSafety(uri.getHost());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起 HEAD 请求(必要时回退 GET Range)并处理重定向
|
||||||
|
HttpResult http = headCheckWithRedirects(uri, props.getMaxRedirects());
|
||||||
|
|
||||||
|
// 内容类型校验
|
||||||
|
String contentType = normalizeContentType(http.contentType);
|
||||||
|
if (!isAllowedContentType(contentType)) {
|
||||||
|
// 允许根据扩展名兜底一次
|
||||||
|
String guessed = guessContentTypeFromPath(http.finalUri.getPath());
|
||||||
|
if (!isAllowedContentType(guessed)) {
|
||||||
|
throw new IllegalArgumentException("不支持的图片类型");
|
||||||
|
}
|
||||||
|
contentType = guessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大小校验(若已知)
|
||||||
|
if (http.contentLength != null && http.contentLength > 0) {
|
||||||
|
if (http.contentLength > props.getMaxSizeBytes()) {
|
||||||
|
throw new IllegalArgumentException("图片过大,超过上限" + props.getMaxSizeMb() + "MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ValidationResult(http.finalUri.toString(), contentType, http.contentLength);
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
throw new IllegalArgumentException("URL不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enforceAllowlist(String host) {
|
||||||
|
List<String> list = props.getAllowlist();
|
||||||
|
if (list == null || list.isEmpty()) return;
|
||||||
|
String h = host.toLowerCase(Locale.ROOT);
|
||||||
|
for (String item : list) {
|
||||||
|
if (!StringUtils.hasText(item)) continue;
|
||||||
|
String rule = item.trim().toLowerCase(Locale.ROOT);
|
||||||
|
if (rule.startsWith("*.")) {
|
||||||
|
String suf = rule.substring(1); // .example.com
|
||||||
|
if (h.endsWith(suf)) return;
|
||||||
|
} else {
|
||||||
|
if (h.equals(rule) || h.endsWith("." + rule)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("域名不在白名单");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enforceIpSafety(String host) {
|
||||||
|
try {
|
||||||
|
InetAddress[] all = InetAddress.getAllByName(host);
|
||||||
|
for (InetAddress addr : all) {
|
||||||
|
if (!isPublicAddress(addr)) {
|
||||||
|
if (!props.isAllowPrivateIp()) {
|
||||||
|
throw new IllegalArgumentException("禁止访问内网/本地地址");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
throw new IllegalArgumentException("无法解析域名");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPublicAddress(InetAddress addr) {
|
||||||
|
return !(addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() ||
|
||||||
|
addr.isSiteLocalAddress() || addr.isMulticastAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResult headCheckWithRedirects(URI uri, int remainingRedirects) {
|
||||||
|
try {
|
||||||
|
URI current = uri;
|
||||||
|
int redirects = 0;
|
||||||
|
while (true) {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) current.toURL().openConnection();
|
||||||
|
conn.setConnectTimeout(props.getConnectTimeoutMs());
|
||||||
|
conn.setReadTimeout(props.getReadTimeoutMs());
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setRequestProperty("User-Agent", "PartsInquiry/1.0");
|
||||||
|
try {
|
||||||
|
conn.setRequestMethod("HEAD");
|
||||||
|
} catch (ProtocolException ignored) {
|
||||||
|
// 一些实现不允许设置,忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
int code;
|
||||||
|
try {
|
||||||
|
code = conn.getResponseCode();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
// 回退到GET Range
|
||||||
|
return getRange0(current, redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRedirect(code)) {
|
||||||
|
if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) {
|
||||||
|
throw new IllegalArgumentException("重定向过多");
|
||||||
|
}
|
||||||
|
String loc = conn.getHeaderField("Location");
|
||||||
|
if (!StringUtils.hasText(loc)) {
|
||||||
|
throw new IllegalArgumentException("重定向无Location");
|
||||||
|
}
|
||||||
|
URI next = current.resolve(loc);
|
||||||
|
if (!"http".equalsIgnoreCase(next.getScheme()) && !"https".equalsIgnoreCase(next.getScheme())) {
|
||||||
|
throw new IllegalArgumentException("非法重定向协议");
|
||||||
|
}
|
||||||
|
// 重定向目标再做一次安全检查
|
||||||
|
enforceAllowlist(next.getHost());
|
||||||
|
if (props.isSsrfProtection()) enforceIpSafety(next.getHost());
|
||||||
|
current = next;
|
||||||
|
redirects++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code >= 200 && code < 300) {
|
||||||
|
String ct = conn.getHeaderField("Content-Type");
|
||||||
|
String cl = conn.getHeaderField("Content-Length");
|
||||||
|
Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl));
|
||||||
|
return new HttpResult(current, ct, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD 不被允许时(405等)回退到 GET Range
|
||||||
|
if (code == HttpURLConnection.HTTP_BAD_METHOD || code == HttpURLConnection.HTTP_FORBIDDEN) {
|
||||||
|
return getRange0(current, redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("URL不可访问,HTTP " + code);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalArgumentException("无法访问URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpResult getRange0(URI uri, int redirects) throws IOException {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
|
||||||
|
conn.setConnectTimeout(props.getConnectTimeoutMs());
|
||||||
|
conn.setReadTimeout(props.getReadTimeoutMs());
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setRequestProperty("User-Agent", "PartsInquiry/1.0");
|
||||||
|
conn.setRequestProperty("Range", "bytes=0-0");
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
if (isRedirect(code)) {
|
||||||
|
if (!props.isFollowRedirects() || redirects >= props.getMaxRedirects()) {
|
||||||
|
throw new IllegalArgumentException("重定向过多");
|
||||||
|
}
|
||||||
|
String loc = conn.getHeaderField("Location");
|
||||||
|
if (!StringUtils.hasText(loc)) throw new IllegalArgumentException("重定向无Location");
|
||||||
|
URI next = uri.resolve(loc);
|
||||||
|
enforceAllowlist(next.getHost());
|
||||||
|
if (props.isSsrfProtection()) enforceIpSafety(next.getHost());
|
||||||
|
return headCheckWithRedirects(next, props.getMaxRedirects() - redirects - 1);
|
||||||
|
}
|
||||||
|
if (code >= 200 && code < 300 || code == HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
String ct = conn.getHeaderField("Content-Type");
|
||||||
|
String cl = conn.getHeaderField("Content-Length");
|
||||||
|
Long len = parseLongSafe(totalFromContentRange(conn.getHeaderField("Content-Range"), cl));
|
||||||
|
return new HttpResult(uri, ct, len);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("URL不可访问,HTTP " + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRedirect(int code) {
|
||||||
|
return code == HttpURLConnection.HTTP_MOVED_PERM || code == HttpURLConnection.HTTP_MOVED_TEMP
|
||||||
|
|| code == HttpURLConnection.HTTP_SEE_OTHER || code == 307 || code == 308;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return null;
|
||||||
|
int idx = ct.indexOf(';');
|
||||||
|
String base = (idx > 0 ? ct.substring(0, idx) : ct).trim().toLowerCase(Locale.ROOT);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAllowedContentType(String ct) {
|
||||||
|
if (!StringUtils.hasText(ct)) return false;
|
||||||
|
for (String allowed : props.getAllowedContentTypes()) {
|
||||||
|
if (ct.equalsIgnoreCase(allowed)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String guessContentTypeFromPath(String path) {
|
||||||
|
if (path == null) return null;
|
||||||
|
String p = path.toLowerCase(Locale.ROOT);
|
||||||
|
if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg";
|
||||||
|
if (p.endsWith(".png")) return "image/png";
|
||||||
|
if (p.endsWith(".gif")) return "image/gif";
|
||||||
|
if (p.endsWith(".webp")) return "image/webp";
|
||||||
|
if (p.endsWith(".svg")) return "image/svg+xml";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String totalFromContentRange(String contentRange, String fallbackLength) {
|
||||||
|
// Content-Range: bytes 0-0/12345 -> 12345
|
||||||
|
if (contentRange != null) {
|
||||||
|
int slash = contentRange.lastIndexOf('/');
|
||||||
|
if (slash > 0 && slash + 1 < contentRange.length()) {
|
||||||
|
String total = contentRange.substring(slash + 1).trim();
|
||||||
|
if (!"*".equals(total)) return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long parseLongSafe(String v) {
|
||||||
|
if (!StringUtils.hasText(v)) return null;
|
||||||
|
try { return Long.parseLong(v.trim()); } catch (Exception e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ValidationResult(String url, String contentType, Long contentLength) {}
|
||||||
|
|
||||||
|
private record HttpResult(URI finalUri, String contentType, Long contentLength) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth/email")
|
||||||
|
public class EmailAuthController {
|
||||||
|
|
||||||
|
private final EmailAuthService emailAuthService;
|
||||||
|
|
||||||
|
public EmailAuthController(EmailAuthService emailAuthService) {
|
||||||
|
this.emailAuthService = emailAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/send")
|
||||||
|
public ResponseEntity<?> send(@RequestBody EmailAuthService.SendCodeRequest req,
|
||||||
|
@RequestHeader(value = "X-Forwarded-For", required = false) String xff,
|
||||||
|
@RequestHeader(value = "X-Real-IP", required = false) String xri) {
|
||||||
|
String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp());
|
||||||
|
EmailAuthService.SendCodeResponse resp = emailAuthService.sendCode(req, ip);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody EmailAuthService.LoginRequest req) {
|
||||||
|
EmailAuthService.LoginResponse resp = emailAuthService.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody EmailAuthService.RegisterRequest req) {
|
||||||
|
EmailAuthService.LoginResponse resp = emailAuthService.register(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public ResponseEntity<?> resetPassword(@RequestBody EmailAuthService.ResetPasswordRequest req) {
|
||||||
|
EmailAuthService.ResetPasswordResponse resp = emailAuthService.resetPassword(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp() {
|
||||||
|
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs instanceof ServletRequestAttributes sra) {
|
||||||
|
var req = sra.getRequest();
|
||||||
|
return req.getRemoteAddr();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import com.example.demo.common.EmailSenderService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmailAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
private final com.example.demo.common.ShopDefaultsProperties shopDefaults;
|
||||||
|
private final EmailSenderService emailSender;
|
||||||
|
|
||||||
|
private final com.example.demo.common.DefaultSeedService defaultSeedService;
|
||||||
|
|
||||||
|
public EmailAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps,
|
||||||
|
com.example.demo.common.ShopDefaultsProperties shopDefaults,
|
||||||
|
EmailSenderService emailSender,
|
||||||
|
com.example.demo.common.DefaultSeedService defaultSeedService) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
this.shopDefaults = shopDefaults;
|
||||||
|
this.emailSender = emailSender;
|
||||||
|
this.defaultSeedService = defaultSeedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SendCodeRequest { public String email; public String scene; }
|
||||||
|
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
|
||||||
|
public static class LoginRequest { public String email; public String code; public String name; }
|
||||||
|
public static class RegisterRequest { public String email; public String code; public String name; public String password; }
|
||||||
|
public static class ResetPasswordRequest { public String email; public String code; public String newPassword; public String confirmPassword; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
public static class ResetPasswordResponse { public boolean ok; }
|
||||||
|
|
||||||
|
private String generateCode() {
|
||||||
|
SecureRandom rng = new SecureRandom();
|
||||||
|
int n = 100000 + rng.nextInt(900000);
|
||||||
|
return String.valueOf(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hmacSha256Hex(String secret, String message) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(md.digest());
|
||||||
|
} catch (Exception e) { throw new RuntimeException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureEmailFormat(String email) {
|
||||||
|
if (email == null || email.isBlank()) throw new IllegalArgumentException("邮箱不能为空");
|
||||||
|
String e = email.trim();
|
||||||
|
if (!e.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) throw new IllegalArgumentException("邮箱格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensurePasswordFormat(String password) {
|
||||||
|
if (password == null || password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
if (password.length() < 6) throw new IllegalArgumentException("密码至少6位");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String,Object> fetchLatestCode(String email, String scene) {
|
||||||
|
String sc = (scene == null || scene.isBlank()) ? "login" : scene.trim();
|
||||||
|
return jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement(
|
||||||
|
"SELECT id, code_hash, salt, expire_at, status, fail_count FROM email_codes WHERE email=? AND scene=? ORDER BY id DESC LIMIT 1");
|
||||||
|
ps.setString(1, email);
|
||||||
|
ps.setString(2, sc);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new java.util.HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("code_hash", rs.getString(2));
|
||||||
|
m.put("salt", rs.getString(3));
|
||||||
|
m.put("expire_at", rs.getTimestamp(4));
|
||||||
|
m.put("status", rs.getInt(5));
|
||||||
|
m.put("fail_count", rs.getInt(6));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAndConsumeCode(String email, String scene, String code) {
|
||||||
|
if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空");
|
||||||
|
String lowerEmail = email.trim().toLowerCase();
|
||||||
|
Map<String,Object> row = fetchLatestCode(lowerEmail, scene);
|
||||||
|
if (row == null && scene != null && !scene.isBlank() && !"login".equals(scene)) {
|
||||||
|
row = fetchLatestCode(lowerEmail, "login");
|
||||||
|
}
|
||||||
|
if (row == null) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
Long id = ((Number)row.get("id")).longValue();
|
||||||
|
int status = ((Number)row.get("status")).intValue();
|
||||||
|
if (status != 0) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at");
|
||||||
|
if (expireAt.before(new java.util.Date())) {
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET status=2 WHERE id=?", id);
|
||||||
|
throw new IllegalArgumentException("CODE_EXPIRED");
|
||||||
|
}
|
||||||
|
int failCount = ((Number)row.get("fail_count")).intValue();
|
||||||
|
if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS");
|
||||||
|
String expect = (String) row.get("code_hash");
|
||||||
|
String salt = (String) row.get("salt");
|
||||||
|
String actual = hmacSha256Hex(salt, code);
|
||||||
|
if (!actual.equalsIgnoreCase(expect)) {
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET fail_count=fail_count+1 WHERE id=?", id);
|
||||||
|
throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE email_codes SET status=1 WHERE id=?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene.trim().toLowerCase();
|
||||||
|
|
||||||
|
Long cntRecent = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(1) FROM email_codes WHERE email=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND",
|
||||||
|
Long.class, email, scene);
|
||||||
|
if (cntRecent != null && cntRecent > 0) {
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = false; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = generateCode();
|
||||||
|
String salt = Long.toHexString(System.nanoTime());
|
||||||
|
String codeHash = hmacSha256Hex(salt, code);
|
||||||
|
int ttl = 300; // 五分钟有效
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO email_codes(email, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())");
|
||||||
|
ps.setString(1, email);
|
||||||
|
ps.setString(2, scene);
|
||||||
|
ps.setString(3, codeHash);
|
||||||
|
ps.setString(4, salt);
|
||||||
|
ps.setInt(5, ttl);
|
||||||
|
ps.setString(6, clientIp);
|
||||||
|
return ps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 真实发信
|
||||||
|
String subject = "邮箱验证码";
|
||||||
|
String content = "您的验证码是 " + code + " ,5分钟内有效。如非本人操作请忽略。";
|
||||||
|
emailSender.sendPlainText(email, subject, content);
|
||||||
|
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = true; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "login", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
Long userId;
|
||||||
|
Long shopId;
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
userId = existing.get(0);
|
||||||
|
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
|
||||||
|
shopId = sids.isEmpty() ? 1L : sids.get(0);
|
||||||
|
Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId);
|
||||||
|
if (st != null && st.intValue() != 1) {
|
||||||
|
throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim();
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
final Long sid = shopId;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, sid);
|
||||||
|
ps.setString(2, email);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
userId = userGenId.longValue();
|
||||||
|
|
||||||
|
// 初始化默认客户/供应商(幂等)
|
||||||
|
defaultSeedService.initializeForShop(shopId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, null, "email_otp", email);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("email", email);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse register(RegisterRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
ensurePasswordFormat(req.password);
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "register", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("邮箱已注册");
|
||||||
|
}
|
||||||
|
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskEmailForName(email) : req.name.trim();
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
|
||||||
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
Long shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, email, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, shopId);
|
||||||
|
ps.setString(2, email);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
Long userId = userGenId.longValue();
|
||||||
|
|
||||||
|
String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.password, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
|
||||||
|
jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId);
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, null, "email_register", email);
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("email", email);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ResetPasswordResponse resetPassword(ResetPasswordRequest req) {
|
||||||
|
ensureEmailFormat(req.email);
|
||||||
|
if (req.newPassword == null || req.newPassword.isBlank()) throw new IllegalArgumentException("新密码不能为空");
|
||||||
|
if (req.confirmPassword == null || !req.newPassword.equals(req.confirmPassword)) {
|
||||||
|
throw new IllegalArgumentException("两次密码不一致");
|
||||||
|
}
|
||||||
|
ensurePasswordFormat(req.newPassword);
|
||||||
|
|
||||||
|
String email = req.email.trim().toLowerCase();
|
||||||
|
validateAndConsumeCode(email, "reset", req.code);
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE email=? LIMIT 1", Long.class, email);
|
||||||
|
if (existing.isEmpty()) throw new IllegalArgumentException("用户不存在");
|
||||||
|
Long userId = existing.get(0);
|
||||||
|
|
||||||
|
String bcrypt = org.springframework.security.crypto.bcrypt.BCrypt.hashpw(req.newPassword, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
|
||||||
|
jdbcTemplate.update("UPDATE users SET password_hash=?, updated_at=NOW() WHERE id=?", bcrypt, userId);
|
||||||
|
|
||||||
|
ResetPasswordResponse resp = new ResetPasswordResponse();
|
||||||
|
resp.ok = true;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskEmailForName(String email) {
|
||||||
|
String e = String.valueOf(email);
|
||||||
|
int at = e.indexOf('@');
|
||||||
|
if (at > 1) {
|
||||||
|
String name = e.substring(0, at);
|
||||||
|
if (name.length() <= 2) return "用户" + name.charAt(0) + "*";
|
||||||
|
return "用户" + name.substring(0, 2) + "***";
|
||||||
|
}
|
||||||
|
return "邮箱用户";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "jwt")
|
||||||
|
public class JwtProperties {
|
||||||
|
private String secret;
|
||||||
|
private String issuer = "parts-inquiry-api";
|
||||||
|
private long ttlSeconds = 7200;
|
||||||
|
private long clockSkewSeconds = 60;
|
||||||
|
|
||||||
|
public String getSecret() { return secret; }
|
||||||
|
public void setSecret(String secret) { this.secret = secret; }
|
||||||
|
public String getIssuer() { return issuer; }
|
||||||
|
public void setIssuer(String issuer) { this.issuer = issuer; }
|
||||||
|
public long getTtlSeconds() { return ttlSeconds; }
|
||||||
|
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
|
||||||
|
public long getClockSkewSeconds() { return clockSkewSeconds; }
|
||||||
|
public void setClockSkewSeconds(long clockSkewSeconds) { this.clockSkewSeconds = clockSkewSeconds; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import com.auth0.jwt.JWT;
|
||||||
|
import com.auth0.jwt.algorithms.Algorithm;
|
||||||
|
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||||
|
import com.auth0.jwt.interfaces.JWTVerifier;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
private final JwtProperties props;
|
||||||
|
|
||||||
|
public JwtService(JwtProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signToken(Long userId, Long shopId, String phone, String provider) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
var jwt = JWT.create()
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.withIssuedAt(java.util.Date.from(now))
|
||||||
|
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
|
||||||
|
.withClaim("userId", userId)
|
||||||
|
.withClaim("shopId", shopId)
|
||||||
|
.withClaim("provider", provider);
|
||||||
|
if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone);
|
||||||
|
return jwt.sign(alg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signToken(Long userId, Long shopId, String phone, String provider, String email) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
var jwt = JWT.create()
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.withIssuedAt(java.util.Date.from(now))
|
||||||
|
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
|
||||||
|
.withClaim("userId", userId)
|
||||||
|
.withClaim("shopId", shopId)
|
||||||
|
.withClaim("provider", provider);
|
||||||
|
if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone);
|
||||||
|
if (email != null && !email.isBlank()) jwt.withClaim("email", email);
|
||||||
|
return jwt.sign(alg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signAdminToken(Long adminId, String username) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
var jwt = JWT.create()
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.withIssuedAt(java.util.Date.from(now))
|
||||||
|
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
|
||||||
|
.withClaim("adminId", adminId)
|
||||||
|
.withClaim("role", "admin");
|
||||||
|
if (username != null && !username.isBlank()) jwt.withClaim("username", username);
|
||||||
|
return jwt.sign(alg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String,Object> parseClaims(String authorizationHeader) {
|
||||||
|
Map<String,Object> out = new HashMap<>();
|
||||||
|
if (authorizationHeader == null || authorizationHeader.isBlank()) return out;
|
||||||
|
String prefix = "Bearer ";
|
||||||
|
if (!authorizationHeader.startsWith(prefix)) return out;
|
||||||
|
String token = authorizationHeader.substring(prefix.length()).trim();
|
||||||
|
try {
|
||||||
|
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
|
||||||
|
JWTVerifier verifier = JWT.require(alg)
|
||||||
|
.withIssuer(props.getIssuer())
|
||||||
|
.acceptLeeway(props.getClockSkewSeconds())
|
||||||
|
.build();
|
||||||
|
DecodedJWT jwt = verifier.verify(token);
|
||||||
|
Long userId = jwt.getClaim("userId").asLong();
|
||||||
|
Long shopId = jwt.getClaim("shopId").asLong();
|
||||||
|
String phone = jwt.getClaim("phone").asString();
|
||||||
|
String email = jwt.getClaim("email").asString();
|
||||||
|
Long adminId = jwt.getClaim("adminId").asLong();
|
||||||
|
String role = jwt.getClaim("role").asString();
|
||||||
|
if (userId != null) out.put("userId", userId);
|
||||||
|
if (shopId != null) out.put("shopId", shopId);
|
||||||
|
if (phone != null && !phone.isBlank()) out.put("phone", phone);
|
||||||
|
if (email != null && !email.isBlank()) out.put("email", email);
|
||||||
|
if (adminId != null) out.put("adminId", adminId);
|
||||||
|
if (role != null && !role.isBlank()) out.put("role", role);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/normal-admin")
|
||||||
|
public class NormalAdminApplyController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public NormalAdminApplyController(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||||
|
|
||||||
|
@PostMapping("/apply")
|
||||||
|
public ResponseEntity<?> apply(@RequestHeader(name = "X-User-Id") long userId,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestBody(required = false) Map<String,Object> body) {
|
||||||
|
final Long sidFinal;
|
||||||
|
if (shopId == null) {
|
||||||
|
Long sid = jdbc.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getLong(1): null);
|
||||||
|
if (sid == null) return ResponseEntity.badRequest().body(Map.of("error", "user not found"));
|
||||||
|
sidFinal = sid;
|
||||||
|
} else { sidFinal = shopId; }
|
||||||
|
// 校验 VIP(根据配置可选)
|
||||||
|
boolean requireVip = true; // 默认要求VIP有效
|
||||||
|
try {
|
||||||
|
String v = jdbc.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.requiredVipActive' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v != null) { v = v.trim(); if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1); requireVip = ("true".equalsIgnoreCase(v) || "1".equals(v)); }
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
Integer vipOk = jdbc.query(
|
||||||
|
"SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, userId); ps.setLong(2, sidFinal); },
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : 0
|
||||||
|
);
|
||||||
|
if (requireVip && (vipOk == null || vipOk != 1)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("error", "vip required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String remark = body == null ? null : Objects.toString(body.get("remark"), null);
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,created_at) VALUES (?,?,?,?,NOW())",
|
||||||
|
ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "apply"); ps.setString(4, remark); });
|
||||||
|
|
||||||
|
// 是否自动通过
|
||||||
|
boolean autoApprove = false;
|
||||||
|
try {
|
||||||
|
String v = jdbc.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.autoApprove' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v != null) { v = v.trim(); if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1); autoApprove = ("true".equalsIgnoreCase(v) || "1".equals(v)); }
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
if (autoApprove) {
|
||||||
|
// 将角色变更为 normal_admin 并写入 approve 审计
|
||||||
|
String prev = jdbc.query("SELECT role FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getString(1): null);
|
||||||
|
jdbc.update("UPDATE users SET role='normal_admin' WHERE id=?", ps -> ps.setLong(1, userId));
|
||||||
|
jdbc.update("INSERT INTO normal_admin_audits(shop_id,user_id,action,remark,operator_admin_id,previous_role,new_role,created_at) VALUES (?,?,?,?,NULL,?,?,NOW())",
|
||||||
|
ps -> { ps.setLong(1, sidFinal); ps.setLong(2, userId); ps.setString(3, "approve"); ps.setString(4, "auto"); ps.setString(5, prev); ps.setString(6, "normal_admin"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/application/status")
|
||||||
|
public ResponseEntity<?> myApplicationStatus(@RequestHeader(name = "X-User-Id") long userId) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
// 当前角色
|
||||||
|
String role = null;
|
||||||
|
try {
|
||||||
|
role = jdbc.query("SELECT role FROM users WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId), rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
boolean isNormalAdmin = role != null && "normal_admin".equalsIgnoreCase(role.trim());
|
||||||
|
|
||||||
|
// 最近一次审计动作
|
||||||
|
Map<String, Object> last = null;
|
||||||
|
try {
|
||||||
|
last = jdbc.query(
|
||||||
|
"SELECT action, created_at AS createdAt, remark FROM normal_admin_audits WHERE user_id=? ORDER BY created_at DESC LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId),
|
||||||
|
rs -> {
|
||||||
|
if (!rs.next()) return null;
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("action", rs.getString("action"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("createdAt"));
|
||||||
|
m.put("remark", rs.getString("remark"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
String applicationStatus = "none";
|
||||||
|
if (isNormalAdmin) {
|
||||||
|
applicationStatus = "approved";
|
||||||
|
} else if (last != null) {
|
||||||
|
String action = (String) last.get("action");
|
||||||
|
if ("apply".equalsIgnoreCase(action)) applicationStatus = "pending";
|
||||||
|
else if ("approve".equalsIgnoreCase(action)) applicationStatus = "approved";
|
||||||
|
else if ("reject".equalsIgnoreCase(action)) applicationStatus = "rejected";
|
||||||
|
else if ("revoke".equalsIgnoreCase(action)) applicationStatus = "revoked";
|
||||||
|
}
|
||||||
|
|
||||||
|
out.put("isNormalAdmin", isNormalAdmin);
|
||||||
|
out.put("applicationStatus", applicationStatus);
|
||||||
|
if (last != null) {
|
||||||
|
out.put("lastAction", last.get("action"));
|
||||||
|
out.put("lastActionAt", last.get("createdAt"));
|
||||||
|
out.put("lastRemark", last.get("remark"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(out);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String,Object> fallback = new LinkedHashMap<>();
|
||||||
|
fallback.put("isNormalAdmin", false);
|
||||||
|
fallback.put("applicationStatus", "none");
|
||||||
|
return ResponseEntity.ok(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
public class PasswordAuthController {
|
||||||
|
|
||||||
|
private final PasswordAuthService service;
|
||||||
|
|
||||||
|
public PasswordAuthController(PasswordAuthService service) { this.service = service; }
|
||||||
|
|
||||||
|
@PostMapping("/password/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody PasswordAuthService.LoginRequest req) {
|
||||||
|
var resp = service.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PasswordAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
|
||||||
|
public PasswordAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LoginRequest { public String account; public String email; public String phone; public String password; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
String account = firstNonBlank(req.account, req.email, req.phone);
|
||||||
|
if (account == null || account.isBlank()) throw new IllegalArgumentException("账号不能为空");
|
||||||
|
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
|
||||||
|
|
||||||
|
boolean byEmail = account.contains("@");
|
||||||
|
Map<String, Object> row = jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement(byEmail
|
||||||
|
? "SELECT id, shop_id, password_hash, status, email FROM users WHERE email=? LIMIT 1"
|
||||||
|
: "SELECT id, shop_id, password_hash, status, phone FROM users WHERE phone=? LIMIT 1");
|
||||||
|
ps.setString(1, account.trim());
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("shop_id", rs.getLong(2));
|
||||||
|
m.put("password_hash", rs.getString(3));
|
||||||
|
m.put("status", rs.getInt(4));
|
||||||
|
m.put("account", rs.getString(5));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (row == null) throw new IllegalArgumentException("用户不存在");
|
||||||
|
int status = ((Number)row.get("status")).intValue();
|
||||||
|
if (status != 1) throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
|
String hash = (String) row.get("password_hash");
|
||||||
|
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
|
||||||
|
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
|
||||||
|
if (!ok) throw new IllegalArgumentException("密码错误");
|
||||||
|
|
||||||
|
Long userId = ((Number)row.get("id")).longValue();
|
||||||
|
Long shopId = ((Number)row.get("shop_id")).longValue();
|
||||||
|
String accValue = String.valueOf(row.get("account"));
|
||||||
|
|
||||||
|
String token = byEmail
|
||||||
|
? jwtService.signToken(userId, shopId, null, "password", accValue)
|
||||||
|
: jwtService.signToken(userId, shopId, accValue, "password");
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
Map<String,Object> userMap = new HashMap<>();
|
||||||
|
userMap.put("userId", userId); userMap.put("shopId", shopId);
|
||||||
|
if (byEmail) userMap.put("email", accValue); else userMap.put("phone", accValue);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String... arr) {
|
||||||
|
if (arr == null) return null;
|
||||||
|
for (String s : arr) { if (s != null && !s.isBlank()) return s; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
public class RegisterController {
|
||||||
|
|
||||||
|
private final RegisterService registerService;
|
||||||
|
|
||||||
|
public RegisterController(RegisterService registerService) {
|
||||||
|
this.registerService = registerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody RegisterService.RegisterRequest req) {
|
||||||
|
var resp = registerService.register(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import com.example.demo.common.ShopDefaultsProperties;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RegisterService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
private final ShopDefaultsProperties shopDefaults;
|
||||||
|
private AppDefaultsProperties appDefaults;
|
||||||
|
private com.example.demo.common.DefaultSeedService defaultSeedService;
|
||||||
|
|
||||||
|
public RegisterService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps,
|
||||||
|
ShopDefaultsProperties shopDefaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
this.shopDefaults = shopDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setAppDefaults(AppDefaultsProperties appDefaults) {
|
||||||
|
this.appDefaults = appDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setDefaultSeedService(com.example.demo.common.DefaultSeedService defaultSeedService) {
|
||||||
|
this.defaultSeedService = defaultSeedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hashPassword(String raw) {
|
||||||
|
try {
|
||||||
|
return org.springframework.security.crypto.bcrypt.BCrypt.hashpw(raw, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("密码加密失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RegisterRequest {
|
||||||
|
public String phone; // 必填:11位
|
||||||
|
public String name; // 可选:默认用脱敏手机号
|
||||||
|
public String password; // 可选:如提供则保存密码哈希
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RegisterResponse {
|
||||||
|
public String token;
|
||||||
|
public long expiresIn;
|
||||||
|
public Map<String,Object> user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public RegisterResponse register(RegisterRequest req) {
|
||||||
|
ensurePhoneFormat(req.phone);
|
||||||
|
String phone = req.phone.trim();
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim();
|
||||||
|
|
||||||
|
// 已存在则直接签发令牌
|
||||||
|
var existing = jdbcTemplate.queryForList("SELECT id, shop_id FROM users WHERE phone=? LIMIT 1", phone);
|
||||||
|
Long userId;
|
||||||
|
Long shopId;
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
Map<String,Object> row = existing.get(0);
|
||||||
|
userId = ((Number)row.get("id")).longValue();
|
||||||
|
shopId = ((Number)row.get("shop_id")).longValue();
|
||||||
|
} else {
|
||||||
|
// 1) 创建店铺
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
GeneratedKeyHolder shopKey = new GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())",
|
||||||
|
java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
// 2) 创建店主用户(owner)
|
||||||
|
GeneratedKeyHolder userKey = new GeneratedKeyHolder();
|
||||||
|
final Long sid = shopId;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO users(shop_id, phone, name, role, password_hash, status, is_owner, created_at, updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,?,1,1,NOW(),NOW())",
|
||||||
|
java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, sid);
|
||||||
|
ps.setString(2, phone);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
// 如提供密码,存入哈希;否则设为 NULL
|
||||||
|
String pwd = (req.password == null || req.password.isBlank()) ? null : hashPassword(req.password);
|
||||||
|
if (pwd != null) ps.setString(5, pwd); else ps.setNull(5, java.sql.Types.VARCHAR);
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
userId = userGenId.longValue();
|
||||||
|
|
||||||
|
// 3) 创建默认账户(现金/银行存款/微信)
|
||||||
|
createDefaultAccounts(shopId, userId);
|
||||||
|
|
||||||
|
// 4) 初始化默认客户/供应商(幂等)
|
||||||
|
if (defaultSeedService != null) {
|
||||||
|
defaultSeedService.initializeForShop(shopId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, phone, "register");
|
||||||
|
RegisterResponse out = new RegisterResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
HashMap<String,Object> userMap = new HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("phone", phone);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createDefaultAccounts(Long shopId, Long userId) {
|
||||||
|
// 现金
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
|
||||||
|
"SELECT ?, ?, ?, 'cash', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
|
||||||
|
shopId, userId, appDefaults.getAccountCashName(), shopId, appDefaults.getAccountCashName());
|
||||||
|
// 银行存款
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
|
||||||
|
"SELECT ?, ?, ?, 'bank', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
|
||||||
|
shopId, userId, appDefaults.getAccountBankName(), shopId, appDefaults.getAccountBankName());
|
||||||
|
// 微信
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
|
||||||
|
"SELECT ?, ?, ?, 'wechat', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
|
||||||
|
shopId, userId, appDefaults.getAccountWechatName(), shopId, appDefaults.getAccountWechatName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensurePhoneFormat(String phone) {
|
||||||
|
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
|
||||||
|
String p = phone.replaceAll("\\s+", "");
|
||||||
|
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhoneForName(String phone) {
|
||||||
|
String p = String.valueOf(phone);
|
||||||
|
if (p.length() == 11) return "用户" + p.substring(0,3) + "****" + p.substring(7);
|
||||||
|
return "手机用户";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth/sms")
|
||||||
|
public class SmsAuthController {
|
||||||
|
|
||||||
|
private final SmsAuthService smsAuthService;
|
||||||
|
|
||||||
|
public SmsAuthController(SmsAuthService smsAuthService) {
|
||||||
|
this.smsAuthService = smsAuthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/send")
|
||||||
|
public ResponseEntity<?> send(@RequestBody SmsAuthService.SendCodeRequest req,
|
||||||
|
@RequestHeader(value = "X-Forwarded-For", required = false) String xff,
|
||||||
|
@RequestHeader(value = "X-Real-IP", required = false) String xri,
|
||||||
|
@RequestHeader(value = "X-Shop-Id", required = false) Long shopId) {
|
||||||
|
String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp());
|
||||||
|
SmsAuthService.SendCodeResponse resp = smsAuthService.sendCode(req, ip);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody SmsAuthService.LoginRequest req) {
|
||||||
|
SmsAuthService.LoginResponse resp = smsAuthService.login(req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp() {
|
||||||
|
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attrs instanceof ServletRequestAttributes sra) {
|
||||||
|
var req = sra.getRequest();
|
||||||
|
return req.getRemoteAddr();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.example.demo.auth;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SmsAuthService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final JwtProperties jwtProps;
|
||||||
|
private final com.example.demo.common.ShopDefaultsProperties shopDefaults;
|
||||||
|
|
||||||
|
public SmsAuthService(JdbcTemplate jdbcTemplate,
|
||||||
|
JwtService jwtService,
|
||||||
|
JwtProperties jwtProps,
|
||||||
|
com.example.demo.common.ShopDefaultsProperties shopDefaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.jwtProps = jwtProps;
|
||||||
|
this.shopDefaults = shopDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SendCodeRequest { public String phone; public String scene; }
|
||||||
|
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
|
||||||
|
public static class LoginRequest { public String phone; public String code; public String name; }
|
||||||
|
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
|
||||||
|
|
||||||
|
private String generateCode() {
|
||||||
|
SecureRandom rng = new SecureRandom();
|
||||||
|
int n = 100000 + rng.nextInt(900000);
|
||||||
|
return String.valueOf(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hmacSha256Hex(String secret, String message) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(md.digest());
|
||||||
|
} catch (Exception e) { throw new RuntimeException(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensurePhoneFormat(String phone) {
|
||||||
|
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
|
||||||
|
String p = phone.replaceAll("\\s+", "");
|
||||||
|
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) {
|
||||||
|
ensurePhoneFormat(req.phone);
|
||||||
|
String phone = req.phone;
|
||||||
|
String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene;
|
||||||
|
|
||||||
|
Long cntRecent = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(1) FROM sms_codes WHERE phone=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND",
|
||||||
|
Long.class, phone, scene);
|
||||||
|
if (cntRecent != null && cntRecent > 0) {
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = false; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
String code = generateCode();
|
||||||
|
String salt = Long.toHexString(System.nanoTime());
|
||||||
|
String codeHash = hmacSha256Hex(salt, code);
|
||||||
|
int ttl = 300; // 五分钟有效
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO sms_codes(phone, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())");
|
||||||
|
ps.setString(1, phone);
|
||||||
|
ps.setString(2, scene);
|
||||||
|
ps.setString(3, codeHash);
|
||||||
|
ps.setString(4, salt);
|
||||||
|
ps.setInt(5, ttl);
|
||||||
|
ps.setString(6, clientIp);
|
||||||
|
return ps;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: 集成真实短信发送;当前仅存表,不外发
|
||||||
|
|
||||||
|
SendCodeResponse out = new SendCodeResponse();
|
||||||
|
out.ok = true; out.cooldownSec = 60;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LoginResponse login(LoginRequest req) {
|
||||||
|
ensurePhoneFormat(req.phone);
|
||||||
|
if (req.code == null || req.code.isBlank()) throw new IllegalArgumentException("验证码不能为空");
|
||||||
|
String phone = req.phone;
|
||||||
|
|
||||||
|
Map<String,Object> row = jdbcTemplate.query(
|
||||||
|
con -> {
|
||||||
|
var ps = con.prepareStatement("SELECT id, code_hash, salt, expire_at, status, fail_count FROM sms_codes WHERE phone=? AND scene='login' ORDER BY id DESC LIMIT 1");
|
||||||
|
ps.setString(1, phone);
|
||||||
|
return ps;
|
||||||
|
},
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
java.util.HashMap<String,Object> m = new java.util.HashMap<>();
|
||||||
|
m.put("id", rs.getLong(1));
|
||||||
|
m.put("code_hash", rs.getString(2));
|
||||||
|
m.put("salt", rs.getString(3));
|
||||||
|
m.put("expire_at", rs.getTimestamp(4));
|
||||||
|
m.put("status", rs.getInt(5));
|
||||||
|
m.put("fail_count", rs.getInt(6));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (row == null) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
int status = (Integer) row.get("status");
|
||||||
|
if (status != 0) throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at");
|
||||||
|
if (expireAt.before(new java.util.Date())) {
|
||||||
|
jdbcTemplate.update("UPDATE sms_codes SET status=2 WHERE id=?", (Long) row.get("id"));
|
||||||
|
throw new IllegalArgumentException("CODE_EXPIRED");
|
||||||
|
}
|
||||||
|
int failCount = (Integer) row.get("fail_count");
|
||||||
|
if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS");
|
||||||
|
|
||||||
|
String expect = (String) row.get("code_hash");
|
||||||
|
String salt = (String) row.get("salt");
|
||||||
|
String actual = hmacSha256Hex(salt, req.code);
|
||||||
|
if (!actual.equalsIgnoreCase(expect)) {
|
||||||
|
jdbcTemplate.update("UPDATE sms_codes SET fail_count=fail_count+1 WHERE id=?", (Long) row.get("id"));
|
||||||
|
throw new IllegalArgumentException("CODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("UPDATE sms_codes SET status=1 WHERE id=?", (Long) row.get("id"));
|
||||||
|
|
||||||
|
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE phone=? LIMIT 1", Long.class, phone);
|
||||||
|
Long userId;
|
||||||
|
Long shopId;
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
userId = existing.get(0);
|
||||||
|
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
|
||||||
|
shopId = sids.isEmpty() ? 1L : sids.get(0);
|
||||||
|
// 拉黑校验:status 必须为 1 才允许登录
|
||||||
|
Integer st = jdbcTemplate.queryForObject("SELECT status FROM users WHERE id=?", Integer.class, userId);
|
||||||
|
if (st != null && st.intValue() != 1) {
|
||||||
|
throw new IllegalArgumentException("你已被管理员拉黑");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim();
|
||||||
|
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
|
||||||
|
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setString(1, shopName);
|
||||||
|
return ps;
|
||||||
|
}, shopKey);
|
||||||
|
Number shopGenId = shopKey.getKey();
|
||||||
|
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
|
||||||
|
shopId = shopGenId.longValue();
|
||||||
|
|
||||||
|
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
final Long sid = shopId;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
var ps = con.prepareStatement("INSERT INTO users(shop_id, phone, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
|
||||||
|
ps.setLong(1, sid);
|
||||||
|
ps.setString(2, phone);
|
||||||
|
ps.setString(3, displayName);
|
||||||
|
ps.setString(4, shopDefaults.getOwnerRole());
|
||||||
|
return ps;
|
||||||
|
}, userKey);
|
||||||
|
Number userGenId = userKey.getKey();
|
||||||
|
if (userGenId == null) throw new IllegalStateException("创建用户失败");
|
||||||
|
userId = userGenId.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtService.signToken(userId, shopId, phone, "sms_otp");
|
||||||
|
LoginResponse out = new LoginResponse();
|
||||||
|
out.token = token;
|
||||||
|
out.expiresIn = jwtProps.getTtlSeconds();
|
||||||
|
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
|
||||||
|
userMap.put("userId", userId);
|
||||||
|
userMap.put("shopId", shopId);
|
||||||
|
userMap.put("phone", phone);
|
||||||
|
out.user = userMap;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhoneForName(String phone) {
|
||||||
|
String p = String.valueOf(phone);
|
||||||
|
if (p.length() == 11) {
|
||||||
|
return "用户" + p.substring(0,3) + "****" + p.substring(7);
|
||||||
|
}
|
||||||
|
return "手机用户";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/barcode")
|
||||||
|
public class BarcodeProxyController {
|
||||||
|
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BarcodeProxyController.class);
|
||||||
|
|
||||||
|
public BarcodeProxyController(PythonBarcodeProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
factory.setReadTimeout((int) Duration.ofSeconds(8).toMillis());
|
||||||
|
this.restTemplate = new RestTemplate(factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/scan", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<String> scan(@RequestPart("file") MultipartFile file) throws IOException {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body("{\"success\":false,\"message\":\"文件为空\"}");
|
||||||
|
}
|
||||||
|
long maxBytes = (long) properties.getMaxUploadMb() * 1024L * 1024L;
|
||||||
|
if (file.getSize() > maxBytes) {
|
||||||
|
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
|
||||||
|
.body(String.format("{\"success\":false,\"message\":\"文件过大(> %dMB)\"}", properties.getMaxUploadMb()));
|
||||||
|
}
|
||||||
|
String url = String.format("http://%s:%d/api/barcode/scan", properties.getHost(), properties.getPort());
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("转发条码识别请求: url={} filename={} size={}B", url, file.getOriginalFilename(), file.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 multipart/form-data 请求转发
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
HttpHeaders fileHeaders = new HttpHeaders();
|
||||||
|
String contentType = file.getContentType();
|
||||||
|
MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
if (contentType != null && !contentType.isBlank()) {
|
||||||
|
try {
|
||||||
|
mediaType = MediaType.parseMediaType(contentType);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileHeaders.setContentType(mediaType);
|
||||||
|
String originalName = file.getOriginalFilename();
|
||||||
|
if (originalName == null || originalName.isBlank()) {
|
||||||
|
originalName = file.getName();
|
||||||
|
}
|
||||||
|
if (originalName == null || originalName.isBlank()) {
|
||||||
|
originalName = "upload.bin";
|
||||||
|
}
|
||||||
|
fileHeaders.setContentDisposition(ContentDisposition.builder("form-data")
|
||||||
|
.name("file")
|
||||||
|
.filename(originalName)
|
||||||
|
.build());
|
||||||
|
final String finalFilename = originalName;
|
||||||
|
ByteArrayResource resource = new ByteArrayResource(file.getBytes()) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return finalFilename;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpEntity<ByteArrayResource> fileEntity = new HttpEntity<>(resource, fileHeaders);
|
||||||
|
body.add("file", fileEntity);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> req = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
ResponseEntity<String> resp = restTemplate.postForEntity(url, req, String.class);
|
||||||
|
long cost = System.currentTimeMillis() - t0;
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
String bodyStr = resp.getBody();
|
||||||
|
if (bodyStr != null && bodyStr.length() > 500) {
|
||||||
|
bodyStr = bodyStr.substring(0, 500) + "...";
|
||||||
|
}
|
||||||
|
log.debug("转发完成: status={} cost={}ms resp={}", resp.getStatusCodeValue(), cost, bodyStr);
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(resp.getStatusCode())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(resp.getBody());
|
||||||
|
} catch (HttpStatusCodeException ex) {
|
||||||
|
String bodyStr = ex.getResponseBodyAsString();
|
||||||
|
if (bodyStr != null && bodyStr.length() > 500) {
|
||||||
|
bodyStr = bodyStr.substring(0, 500) + "...";
|
||||||
|
}
|
||||||
|
log.warn("Python 服务返回非 2xx: status={} body={}", ex.getStatusCode(), bodyStr);
|
||||||
|
MediaType respType = ex.getResponseHeaders() != null
|
||||||
|
? ex.getResponseHeaders().getContentType()
|
||||||
|
: MediaType.APPLICATION_JSON;
|
||||||
|
if (respType == null) {
|
||||||
|
respType = MediaType.APPLICATION_JSON;
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
.contentType(respType)
|
||||||
|
.body(ex.getResponseBodyAsString());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// Python 服务不可用或超时等异常
|
||||||
|
log.warn("转发到 Python 服务失败: {}:{} path=/api/barcode/scan, err={}", properties.getHost(), properties.getPort(), ex.toString());
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body("{\"success\":false,\"message\":\"识别服务不可用,请稍后重试\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PythonBarcodeAutoStarter implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PythonBarcodeAutoStarter.class);
|
||||||
|
private final PythonBarcodeProcessManager manager;
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
|
||||||
|
public PythonBarcodeAutoStarter(PythonBarcodeProcessManager manager, PythonBarcodeProperties properties) {
|
||||||
|
this.manager = manager;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
log.info("Python 条码识别服务未启用 (python.barcode.enabled=false)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("启动 Python 条码识别服务...");
|
||||||
|
manager.startIfEnabled();
|
||||||
|
log.info("Python 条码识别服务已就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void onShutdown() {
|
||||||
|
if (properties.isEnabled()) {
|
||||||
|
log.info("停止 Python 条码识别服务...");
|
||||||
|
manager.stopIfRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PythonBarcodeProcessManager {
|
||||||
|
|
||||||
|
private final PythonBarcodeProperties properties;
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private Process process;
|
||||||
|
|
||||||
|
public PythonBarcodeProcessManager(PythonBarcodeProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||||
|
factory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
factory.setReadTimeout((int) Duration.ofSeconds(2).toMillis());
|
||||||
|
this.restTemplate = new RestTemplate(factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void startIfEnabled() {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(properties.getPython());
|
||||||
|
if (properties.isUseModuleMain()) {
|
||||||
|
cmd.add("-m");
|
||||||
|
cmd.add(properties.getAppModule());
|
||||||
|
} else {
|
||||||
|
// 预留:可扩展为自定义脚本路径
|
||||||
|
cmd.add("-m");
|
||||||
|
cmd.add(properties.getAppModule());
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(new File(properties.getWorkingDir()));
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
if (StringUtils.hasText(properties.getLogFile())) {
|
||||||
|
pb.redirectOutput(new File(properties.getLogFile()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process = pb.start();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("启动 Python 条码服务失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待健康检查
|
||||||
|
long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(properties.getStartupTimeoutSec());
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
if (checkHealth()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(500);
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Python 条码服务在超时时间内未就绪");
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void stopIfRunning() {
|
||||||
|
if (process != null) {
|
||||||
|
process.destroy();
|
||||||
|
try {
|
||||||
|
if (!process.waitFor(2, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAlive() {
|
||||||
|
return process != null && process.isAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkHealth() {
|
||||||
|
String url = String.format("http://%s:%d%s", properties.getHost(), properties.getPort(), properties.getHealthPath());
|
||||||
|
try {
|
||||||
|
restTemplate.getForObject(url, String.class);
|
||||||
|
return true;
|
||||||
|
} catch (RestClientException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.example.demo.barcode;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "python.barcode")
|
||||||
|
public class PythonBarcodeProperties {
|
||||||
|
|
||||||
|
/** 是否在后端启动时同时启动 Python 服务 */
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/** Python 服务运行目录(需包含 app 与 config 目录),相对后端工作目录 */
|
||||||
|
private String workingDir = "./txm";
|
||||||
|
|
||||||
|
/** Python 解释器命令(如 python 或 python3 或 venv 路径)*/
|
||||||
|
private String python = "python";
|
||||||
|
|
||||||
|
/** 以模块方式启动的模块名(例如 app.server.main)*/
|
||||||
|
private String appModule = "app.server.main";
|
||||||
|
|
||||||
|
/** 是否使用 `python -m app.server.main` 启动(否则自行指定命令)*/
|
||||||
|
private boolean useModuleMain = true;
|
||||||
|
|
||||||
|
/** Python 服务监听地址(供 Java 代理转发与健康探测用)*/
|
||||||
|
private String host = "127.0.0.1";
|
||||||
|
|
||||||
|
/** Python 服务监听端口 */
|
||||||
|
private int port = 8000;
|
||||||
|
|
||||||
|
/** 健康检查路径(GET),FastAPI 默认可用 openapi.json */
|
||||||
|
private String healthPath = "/openapi.json";
|
||||||
|
|
||||||
|
/** 启动等待超时(秒)*/
|
||||||
|
private int startupTimeoutSec = 20;
|
||||||
|
|
||||||
|
/** 可选:将 Python 输出重定向到文件(为空则继承控制台)*/
|
||||||
|
private String logFile = "";
|
||||||
|
|
||||||
|
/** 上传大小限制(MB),用于 Java 侧预校验,需与 Python 端配置保持一致 */
|
||||||
|
private int maxUploadMb = 8;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWorkingDir() {
|
||||||
|
return workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWorkingDir(String workingDir) {
|
||||||
|
this.workingDir = workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPython() {
|
||||||
|
return python;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPython(String python) {
|
||||||
|
this.python = python;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAppModule() {
|
||||||
|
return appModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppModule(String appModule) {
|
||||||
|
this.appModule = appModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUseModuleMain() {
|
||||||
|
return useModuleMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUseModuleMain(boolean useModuleMain) {
|
||||||
|
this.useModuleMain = useModuleMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHost() {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHost(String host) {
|
||||||
|
this.host = host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPort() {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPort(int port) {
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHealthPath() {
|
||||||
|
return healthPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHealthPath(String healthPath) {
|
||||||
|
this.healthPath = healthPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStartupTimeoutSec() {
|
||||||
|
return startupTimeoutSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartupTimeoutSec(int startupTimeoutSec) {
|
||||||
|
this.startupTimeoutSec = startupTimeoutSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLogFile() {
|
||||||
|
return logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLogFile(String logFile) {
|
||||||
|
this.logFile = logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxUploadMb() {
|
||||||
|
return maxUploadMb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxUploadMb(int maxUploadMb) {
|
||||||
|
this.maxUploadMb = maxUploadMb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.account.defaults")
|
||||||
|
public class AccountDefaultsProperties {
|
||||||
|
|
||||||
|
private String cashName = "现金";
|
||||||
|
private String bankName = "银行存款";
|
||||||
|
private String wechatName = "微信";
|
||||||
|
private String alipayName = "支付宝";
|
||||||
|
|
||||||
|
public String getCashName() { return cashName; }
|
||||||
|
public void setCashName(String cashName) { this.cashName = cashName; }
|
||||||
|
public String getBankName() { return bankName; }
|
||||||
|
public void setBankName(String bankName) { this.bankName = bankName; }
|
||||||
|
public String getWechatName() { return wechatName; }
|
||||||
|
public void setWechatName(String wechatName) { this.wechatName = wechatName; }
|
||||||
|
public String getAlipayName() { return alipayName; }
|
||||||
|
public void setAlipayName(String alipayName) { this.alipayName = alipayName; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AdminAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Value("${admin.auth.header-name:X-Admin-Id}")
|
||||||
|
private String adminHeaderName;
|
||||||
|
|
||||||
|
public AdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
|
||||||
|
// 预检请求直接放行(由 CORS 处理器返回允许头)
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 允许登录端点无鉴权
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
if (path != null && path.startsWith("/api/admin/auth/login")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先支持 Bearer Token
|
||||||
|
String authorization = request.getHeader("Authorization");
|
||||||
|
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils
|
||||||
|
.getRequiredWebApplicationContext(request.getServletContext())
|
||||||
|
.getBean(com.example.demo.auth.JwtService.class);
|
||||||
|
java.util.Map<String,Object> claims = jwtSvc.parseClaims(authorization);
|
||||||
|
Object aid = claims.get("adminId");
|
||||||
|
if (aid instanceof Long a) {
|
||||||
|
Integer status = jdbcTemplate.query(
|
||||||
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, a),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (status != null && status == 1) return true;
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到兼容请求头 X-Admin-Id
|
||||||
|
String adminIdHeader = request.getHeader(adminHeaderName);
|
||||||
|
if (adminIdHeader == null || adminIdHeader.isBlank()) {
|
||||||
|
// 进一步兼容:若前端仍使用 X-User-Id,则尝试以其作为管理员ID进行校验
|
||||||
|
String userIdHeader = request.getHeader("X-User-Id");
|
||||||
|
if (userIdHeader != null && !userIdHeader.isBlank()) {
|
||||||
|
try {
|
||||||
|
Long maybeAdminId = Long.valueOf(userIdHeader);
|
||||||
|
Integer stat = jdbcTemplate.query(
|
||||||
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, maybeAdminId),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (stat != null && stat == 1) return true;
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
}
|
||||||
|
response.sendError(401, "missing " + adminHeaderName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Long adminId;
|
||||||
|
try { adminId = Long.valueOf(adminIdHeader); } catch (Exception e) { response.sendError(401, "invalid admin"); return false; }
|
||||||
|
Integer status = jdbcTemplate.query(
|
||||||
|
"SELECT status FROM admins WHERE id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, adminId),
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : null
|
||||||
|
);
|
||||||
|
if (status == null || status != 1) {
|
||||||
|
response.sendError(403, "forbidden");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.defaults")
|
||||||
|
public class AppDefaultsProperties {
|
||||||
|
|
||||||
|
private Long shopId = 1L;
|
||||||
|
private Long userId = 2L;
|
||||||
|
// 字典(全局)使用的虚拟店铺ID,方案A:shop_id=0 代表全局共享
|
||||||
|
private Long dictShopId = 0L;
|
||||||
|
|
||||||
|
// 默认账户名称(可配置,避免硬编码)
|
||||||
|
private String accountCashName = "现金";
|
||||||
|
private String accountBankName = "银行存款";
|
||||||
|
private String accountWechatName = "微信";
|
||||||
|
private String accountAlipayName = "支付宝";
|
||||||
|
|
||||||
|
// 默认往来单位名称(配置化,避免硬编码)
|
||||||
|
private String customerName = "散客";
|
||||||
|
private String supplierName = "默认供应商";
|
||||||
|
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
|
||||||
|
public Long getDictShopId() { return dictShopId; }
|
||||||
|
public void setDictShopId(Long dictShopId) { this.dictShopId = dictShopId; }
|
||||||
|
|
||||||
|
public String getAccountCashName() { return accountCashName; }
|
||||||
|
public void setAccountCashName(String accountCashName) { this.accountCashName = accountCashName; }
|
||||||
|
public String getAccountBankName() { return accountBankName; }
|
||||||
|
public void setAccountBankName(String accountBankName) { this.accountBankName = accountBankName; }
|
||||||
|
public String getAccountWechatName() { return accountWechatName; }
|
||||||
|
public void setAccountWechatName(String accountWechatName) { this.accountWechatName = accountWechatName; }
|
||||||
|
public String getAccountAlipayName() { return accountAlipayName; }
|
||||||
|
public void setAccountAlipayName(String accountAlipayName) { this.accountAlipayName = accountAlipayName; }
|
||||||
|
|
||||||
|
public String getCustomerName() { return customerName; }
|
||||||
|
public void setCustomerName(String customerName) { this.customerName = customerName; }
|
||||||
|
|
||||||
|
public String getSupplierName() { return supplierName; }
|
||||||
|
public void setSupplierName(String supplierName) { this.supplierName = supplierName; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Value("${app.cors.allowed-origins:*}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
|
String[] origins = Arrays.stream(allowedOrigins.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toArray(String[]::new);
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOrigins(origins)
|
||||||
|
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.exposedHeaders("Content-Disposition")
|
||||||
|
.allowCredentials(false)
|
||||||
|
.maxAge(3600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DefaultSeedService {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties appDefaults;
|
||||||
|
|
||||||
|
public DefaultSeedService(JdbcTemplate jdbcTemplate, AppDefaultsProperties appDefaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.appDefaults = appDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等初始化:为新店铺创建默认客户/供应商(若不存在)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void initializeForShop(Long shopId, Long userId) {
|
||||||
|
if (shopId == null || userId == null) return;
|
||||||
|
// 默认客户
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO customers (shop_id,user_id,name,price_level,status,created_at,updated_at) " +
|
||||||
|
"SELECT ?, ?, ?, 'retail', 1, NOW(), NOW() FROM DUAL " +
|
||||||
|
"WHERE NOT EXISTS (SELECT 1 FROM customers WHERE shop_id=? AND name=?)",
|
||||||
|
shopId, userId, appDefaults.getCustomerName(), shopId, appDefaults.getCustomerName()
|
||||||
|
);
|
||||||
|
// 默认供应商
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO suppliers (shop_id,user_id,name,status,created_at,updated_at) " +
|
||||||
|
"SELECT ?, ?, ?, 1, NOW(), NOW() FROM DUAL " +
|
||||||
|
"WHERE NOT EXISTS (SELECT 1 FROM suppliers WHERE shop_id=? AND name=?)",
|
||||||
|
shopId, userId, appDefaults.getSupplierName(), shopId, appDefaults.getSupplierName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.mail")
|
||||||
|
public class EmailProperties {
|
||||||
|
private String from;
|
||||||
|
private String subjectPrefix;
|
||||||
|
|
||||||
|
public String getFrom() { return from; }
|
||||||
|
public void setFrom(String from) { this.from = from; }
|
||||||
|
|
||||||
|
public String getSubjectPrefix() { return subjectPrefix; }
|
||||||
|
public void setSubjectPrefix(String subjectPrefix) { this.subjectPrefix = subjectPrefix; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EmailSenderService {
|
||||||
|
private final JavaMailSender mailSender;
|
||||||
|
private final EmailProperties props;
|
||||||
|
|
||||||
|
@Value("${spring.mail.username:}")
|
||||||
|
private String mailUsername;
|
||||||
|
|
||||||
|
public EmailSenderService(JavaMailSender mailSender, EmailProperties props) {
|
||||||
|
this.mailSender = mailSender;
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendPlainText(String to, String subject, String content) {
|
||||||
|
if (to == null || to.isBlank()) throw new IllegalArgumentException("收件人邮箱不能为空");
|
||||||
|
if (subject == null) subject = "";
|
||||||
|
if (content == null) content = "";
|
||||||
|
try {
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
|
||||||
|
helper.setFrom(resolveFromAddress());
|
||||||
|
helper.setTo(to.trim());
|
||||||
|
helper.setSubject(composeSubject(subject));
|
||||||
|
helper.setText(content, false);
|
||||||
|
mailSender.send(message);
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("发送邮件失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String composeSubject(String subject) {
|
||||||
|
String prefix = props.getSubjectPrefix();
|
||||||
|
if (prefix == null || prefix.isBlank()) return subject;
|
||||||
|
return prefix + " " + subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromAddress() {
|
||||||
|
String from = props.getFrom();
|
||||||
|
if (from == null || from.isBlank()) from = mailUsername;
|
||||||
|
if (from == null || from.isBlank()) {
|
||||||
|
throw new IllegalStateException("邮件服务未配置,请设置 MAIL_USERNAME/MAIL_PASSWORD 以及 MAIL_FROM 或 spring.mail.username");
|
||||||
|
}
|
||||||
|
return from.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class FinanceController {
|
||||||
|
|
||||||
|
private final FinanceService financeService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public FinanceController(FinanceService financeService, AppDefaultsProperties defaults) {
|
||||||
|
this.financeService = financeService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/finance/categories")
|
||||||
|
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Map<String, Object> body = financeService.getCategories(sid);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.finance")
|
||||||
|
public class FinanceDefaultsProperties {
|
||||||
|
|
||||||
|
// 形如 key:label, key:label 用逗号分隔
|
||||||
|
private String incomeCategories;
|
||||||
|
private String expenseCategories;
|
||||||
|
|
||||||
|
public String getIncomeCategories() { return incomeCategories; }
|
||||||
|
public void setIncomeCategories(String incomeCategories) { this.incomeCategories = incomeCategories; }
|
||||||
|
|
||||||
|
public String getExpenseCategories() { return expenseCategories; }
|
||||||
|
public void setExpenseCategories(String expenseCategories) { this.expenseCategories = expenseCategories; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class FinanceService {
|
||||||
|
|
||||||
|
private static final String PARAM_KEY = "finance.categories";
|
||||||
|
|
||||||
|
private final SystemParameterRepository systemParameterRepository;
|
||||||
|
private final FinanceDefaultsProperties financeDefaultsProperties;
|
||||||
|
private final javax.sql.DataSource dataSource;
|
||||||
|
private final AppDefaultsProperties appDefaults;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public FinanceService(SystemParameterRepository systemParameterRepository, FinanceDefaultsProperties financeDefaultsProperties, javax.sql.DataSource dataSource, AppDefaultsProperties appDefaults) {
|
||||||
|
this.systemParameterRepository = systemParameterRepository;
|
||||||
|
this.financeDefaultsProperties = financeDefaultsProperties;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
this.appDefaults = appDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getCategories(Long shopId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
// 0) 优先从 finance_categories 表读取(避免中文乱码/统一排序)
|
||||||
|
List<Map<String, String>> income = queryCategoriesFromTable(shopId, "income");
|
||||||
|
List<Map<String, String>> expense = queryCategoriesFromTable(shopId, "expense");
|
||||||
|
|
||||||
|
// 1) 回落读取 system_parameters
|
||||||
|
try {
|
||||||
|
if (income == null || income.isEmpty() || expense == null || expense.isEmpty()) {
|
||||||
|
Optional<SystemParameter> opt = systemParameterRepository.findByShopIdAndKey(shopId, PARAM_KEY);
|
||||||
|
if (opt.isPresent()) {
|
||||||
|
String json = opt.get().getValue();
|
||||||
|
if (json != null && !json.isBlank()) {
|
||||||
|
JsonNode root = objectMapper.readTree(json);
|
||||||
|
if (income == null || income.isEmpty()) {
|
||||||
|
JsonNode incNode = root.get("income");
|
||||||
|
if (incNode != null && incNode.isArray()) {
|
||||||
|
income = objectMapper.convertValue(incNode, new TypeReference<List<Map<String,String>>>(){});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expense == null || expense.isEmpty()) {
|
||||||
|
JsonNode expNode = root.get("expense");
|
||||||
|
if (expNode != null && expNode.isArray()) {
|
||||||
|
expense = objectMapper.convertValue(expNode, new TypeReference<List<Map<String,String>>>(){});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 忽略异常,回落至默认配置
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 回落:应用配置 app.finance.*
|
||||||
|
if (income == null || income.isEmpty()) {
|
||||||
|
income = parsePairs(financeDefaultsProperties.getIncomeCategories());
|
||||||
|
}
|
||||||
|
if (expense == null || expense.isEmpty()) {
|
||||||
|
expense = parsePairs(financeDefaultsProperties.getExpenseCategories());
|
||||||
|
}
|
||||||
|
|
||||||
|
body.put("incomeCategories", income);
|
||||||
|
body.put("expenseCategories", expense);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, String>> parsePairs(String pairs) {
|
||||||
|
if (pairs == null || pairs.isBlank()) return Collections.emptyList();
|
||||||
|
return Arrays.stream(pairs.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.map(s -> {
|
||||||
|
int idx = s.indexOf(":");
|
||||||
|
String key = idx > 0 ? s.substring(0, idx).trim() : s.trim();
|
||||||
|
String label = idx > 0 ? s.substring(idx + 1).trim() : key;
|
||||||
|
Map<String, String> m = new HashMap<>();
|
||||||
|
m.put("key", key);
|
||||||
|
m.put("label", label);
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, String>> queryCategoriesFromTable(Long shopId, String type) {
|
||||||
|
Long fallbackShopId = appDefaults == null ? 1L : (appDefaults.getShopId() == null ? 1L : appDefaults.getShopId());
|
||||||
|
Long dictShopId = appDefaults == null ? 1000L : (appDefaults.getDictShopId() == null ? 1000L : appDefaults.getDictShopId());
|
||||||
|
try (java.sql.Connection c = dataSource.getConnection();
|
||||||
|
java.sql.PreparedStatement ps = c.prepareStatement(
|
||||||
|
"SELECT shop_id, `key`, label FROM finance_categories WHERE shop_id IN (?, ?, ?) AND type=? AND status=1 " +
|
||||||
|
"ORDER BY CASE WHEN shop_id=? THEN 0 WHEN shop_id=? THEN 1 ELSE 2 END, sort_order, id")) {
|
||||||
|
ps.setLong(1, shopId);
|
||||||
|
ps.setLong(2, fallbackShopId);
|
||||||
|
ps.setLong(3, dictShopId);
|
||||||
|
ps.setString(4, type);
|
||||||
|
ps.setLong(5, shopId);
|
||||||
|
ps.setLong(6, dictShopId);
|
||||||
|
try (java.sql.ResultSet rs = ps.executeQuery()) {
|
||||||
|
java.util.Map<String,String> firstByKey = new java.util.LinkedHashMap<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
String key = rs.getString(2);
|
||||||
|
String label = rs.getString(3);
|
||||||
|
if (!firstByKey.containsKey(key)) {
|
||||||
|
firstByKey.put(key, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
java.util.List<java.util.Map<String,String>> list = new java.util.ArrayList<>();
|
||||||
|
for (java.util.Map.Entry<String,String> e : firstByKey.entrySet()) {
|
||||||
|
java.util.Map<String,String> m = new java.util.HashMap<>();
|
||||||
|
m.put("key", e.getKey());
|
||||||
|
m.put("label", e.getValue());
|
||||||
|
list.add(m);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataAccessException;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String,Object>> badRequest(String message) {
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("message", message);
|
||||||
|
return ResponseEntity.badRequest().body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<?> handleIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
log.warn("Bad request: {}", ex.getMessage());
|
||||||
|
return badRequest(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public ResponseEntity<?> handleIllegalState(IllegalStateException ex) {
|
||||||
|
log.warn("Illegal state: {}", ex.getMessage());
|
||||||
|
return badRequest(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(DataAccessException.class)
|
||||||
|
public ResponseEntity<?> handleDataAccess(DataAccessException ex) {
|
||||||
|
log.error("DataAccessException", ex);
|
||||||
|
return badRequest("数据库操作失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<?> handleAny(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
Map<String,Object> body = new HashMap<>();
|
||||||
|
body.put("message", ex.getMessage() == null ? "Internal Server Error" : ex.getMessage());
|
||||||
|
// 附加异常类型,便于前端调试
|
||||||
|
body.put("error", ex.getClass().getSimpleName());
|
||||||
|
return ResponseEntity.status(500).body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
public final class JsonUtils {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private JsonUtils() {}
|
||||||
|
|
||||||
|
public static String toJson(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("JSON 序列化失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJson(String json, Class<T> clazz) {
|
||||||
|
if (json == null || json.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(json, clazz);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("JSON 解析失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> T fromJson(String json, TypeReference<T> type) {
|
||||||
|
if (json == null || json.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(json, type);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalArgumentException("JSON 解析失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 普通管理端(admin-lite)鉴权拦截器
|
||||||
|
* 要求:
|
||||||
|
* - 仅拦截 /api/normal-admin/parts/**
|
||||||
|
* - 通过 X-User-Id 校验 users.status=1 且 role='normal_admin'
|
||||||
|
* - 若要求 VIP 有效(NORMAL_ADMIN_REQUIRE_VIP_ACTIVE=true),校验 vip_users 有效期
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NormalAdminAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public NormalAdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
|
||||||
|
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 仅拦截 /api/normal-admin/parts/**
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
if (path == null || !path.startsWith("/api/normal-admin/parts/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String userIdHeader = request.getHeader("X-User-Id");
|
||||||
|
if (userIdHeader == null || userIdHeader.isBlank()) {
|
||||||
|
response.sendError(401, "missing X-User-Id");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
long userId;
|
||||||
|
try { userId = Long.parseLong(userIdHeader); } catch (Exception e) { response.sendError(401, "invalid user"); return false; }
|
||||||
|
|
||||||
|
// 校验普通管理员角色
|
||||||
|
var row = jdbcTemplate.query(
|
||||||
|
"SELECT u.status,u.role,u.shop_id FROM users u WHERE u.id=? LIMIT 1",
|
||||||
|
ps -> ps.setLong(1, userId),
|
||||||
|
rs -> rs.next() ? new Object[]{ rs.getInt(1), rs.getString(2), rs.getLong(3) } : null
|
||||||
|
);
|
||||||
|
if (row == null) { response.sendError(401, "user not found"); return false; }
|
||||||
|
int status = (int) row[0];
|
||||||
|
String role = (String) row[1];
|
||||||
|
long shopId = (long) row[2];
|
||||||
|
if (status != 1 || role == null || !"normal_admin".equalsIgnoreCase(role.trim())) {
|
||||||
|
response.sendError(403, "forbidden");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选校验:VIP 有效
|
||||||
|
boolean requireVip;
|
||||||
|
try {
|
||||||
|
String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='normalAdmin.requiredVipActive' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v == null) requireVip = true; else {
|
||||||
|
v = v.trim();
|
||||||
|
if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length()-1);
|
||||||
|
requireVip = "true".equalsIgnoreCase(v) || "1".equals(v);
|
||||||
|
}
|
||||||
|
} catch (Exception e) { requireVip = true; }
|
||||||
|
if (requireVip) {
|
||||||
|
Integer vipOk = jdbcTemplate.query(
|
||||||
|
"SELECT CASE WHEN (is_vip=1 AND status=1 AND (expire_at IS NULL OR expire_at>NOW())) THEN 1 ELSE 0 END FROM vip_users WHERE user_id=? AND shop_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, userId); ps.setLong(2, shopId); },
|
||||||
|
rs -> rs.next() ? rs.getInt(1) : 0
|
||||||
|
);
|
||||||
|
if (vipOk == null || vipOk != 1) {
|
||||||
|
response.sendError(403, "vip expired or not active");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过:允许进入控制器
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RequestLoggingFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
String method = request.getMethod();
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
String query = request.getQueryString();
|
||||||
|
String shopId = request.getHeader("X-Shop-Id");
|
||||||
|
String userId = request.getHeader("X-User-Id");
|
||||||
|
if ((shopId == null || shopId.isBlank()) && (userId == null || userId.isBlank())) {
|
||||||
|
String auth = request.getHeader("Authorization");
|
||||||
|
try {
|
||||||
|
com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils
|
||||||
|
.getRequiredWebApplicationContext(request.getServletContext())
|
||||||
|
.getBean(com.example.demo.auth.JwtService.class);
|
||||||
|
java.util.Map<String,Object> claims = jwtSvc.parseClaims(auth);
|
||||||
|
Object sid = claims.get("shopId");
|
||||||
|
Object uid = claims.get("userId");
|
||||||
|
if (sid != null) shopId = String.valueOf(sid);
|
||||||
|
if (uid != null) userId = String.valueOf(uid);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
} finally {
|
||||||
|
long cost = System.currentTimeMillis() - start;
|
||||||
|
int status = response.getStatus();
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("{} {}{} | status={} cost={}ms | shopId={} userId={}",
|
||||||
|
method,
|
||||||
|
uri,
|
||||||
|
(query == null ? "" : ("?" + query)),
|
||||||
|
status,
|
||||||
|
cost,
|
||||||
|
(shopId == null ? "" : shopId),
|
||||||
|
(userId == null ? "" : userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "search.fuzzy")
|
||||||
|
public class SearchFuzzyProperties {
|
||||||
|
|
||||||
|
private boolean enabled = true;
|
||||||
|
private BigDecimal defaultTolerance = new BigDecimal("1.0");
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
public BigDecimal getDefaultTolerance() { return defaultTolerance; }
|
||||||
|
public void setDefaultTolerance(BigDecimal defaultTolerance) { this.defaultTolerance = defaultTolerance; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app.shop")
|
||||||
|
public class ShopDefaultsProperties {
|
||||||
|
|
||||||
|
// 店铺名称规则,使用 String.format 占位:%s -> 用户名
|
||||||
|
private String namePattern = "%s_1";
|
||||||
|
|
||||||
|
// 店主角色名称
|
||||||
|
private String ownerRole = "owner";
|
||||||
|
|
||||||
|
public String getNamePattern() { return namePattern; }
|
||||||
|
public void setNamePattern(String namePattern) { this.namePattern = namePattern; }
|
||||||
|
|
||||||
|
public String getOwnerRole() { return ownerRole; }
|
||||||
|
public void setOwnerRole(String ownerRole) { this.ownerRole = ownerRole; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "system_parameters")
|
||||||
|
public class SystemParameter {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "`key`", nullable = false, length = 64)
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
@Column(name = "value", nullable = false, columnDefinition = "JSON")
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public String getKey() { return key; }
|
||||||
|
public void setKey(String key) { this.key = key; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
public void setValue(String value) { this.value = value; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface SystemParameterRepository extends JpaRepository<SystemParameter, Long> {
|
||||||
|
Optional<SystemParameter> findByShopIdAndKey(Long shopId, String key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.example.demo.common;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final AdminAuthInterceptor adminAuthInterceptor;
|
||||||
|
private final NormalAdminAuthInterceptor normalAdminAuthInterceptor;
|
||||||
|
|
||||||
|
public WebConfig(AdminAuthInterceptor adminAuthInterceptor, NormalAdminAuthInterceptor normalAdminAuthInterceptor) {
|
||||||
|
this.adminAuthInterceptor = adminAuthInterceptor;
|
||||||
|
this.normalAdminAuthInterceptor = normalAdminAuthInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(@NonNull InterceptorRegistry registry) {
|
||||||
|
// 注册管理端鉴权拦截器:保护 /api/admin/**
|
||||||
|
InterceptorRegistration r = registry.addInterceptor(adminAuthInterceptor);
|
||||||
|
r.addPathPatterns("/api/admin/**");
|
||||||
|
// 放行登录接口
|
||||||
|
r.excludePathPatterns("/api/admin/auth/login");
|
||||||
|
|
||||||
|
// 注册普通管理端拦截器:保护 /api/normal-admin/parts/**
|
||||||
|
InterceptorRegistration nr = registry.addInterceptor(normalAdminAuthInterceptor);
|
||||||
|
nr.addPathPatterns("/api/normal-admin/parts/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(@NonNull ResourceHandlerRegistry registry) {
|
||||||
|
// 将 /static/** 映射到前端静态资源目录(开发时)与 classpath 静态目录(部署时)
|
||||||
|
String userDir = System.getProperty("user.dir");
|
||||||
|
String frontendStatic = userDir + java.io.File.separator + "frontend" + java.io.File.separator + "static" + java.io.File.separator;
|
||||||
|
registry.addResourceHandler("/static/**")
|
||||||
|
.addResourceLocations(
|
||||||
|
"file:" + frontendStatic,
|
||||||
|
"classpath:/static/",
|
||||||
|
"classpath:/public/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.example.demo.consult;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/consults")
|
||||||
|
public class ConsultController {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ConsultController(JdbcTemplate jdbcTemplate, AppDefaultsProperties defaults) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
if (body == null) body = new HashMap<>();
|
||||||
|
String topic = ""; // 主题字段已废弃
|
||||||
|
String message = Optional.ofNullable(body.get("message")).map(String::valueOf).orElse(null);
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "message required"));
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("INSERT INTO consults (shop_id,user_id,topic,message,status,created_at,updated_at) VALUES (?,?,?,?, 'open', NOW(), NOW())",
|
||||||
|
sid, uid, topic, message);
|
||||||
|
Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||||
|
Map<String,Object> resp = new HashMap<>();
|
||||||
|
resp.put("id", id);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/latest")
|
||||||
|
public ResponseEntity<?> latest(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
List<Map<String, Object>> list = jdbcTemplate.query(
|
||||||
|
"SELECT id, topic, message, status, created_at FROM consults WHERE shop_id=? AND user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
ps -> { ps.setLong(1, sid); ps.setLong(2, uid); },
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(Collections.emptyMap());
|
||||||
|
}
|
||||||
|
Map<String,Object> latest = list.get(0);
|
||||||
|
Object idObj = latest.get("id");
|
||||||
|
Long consultId = (idObj instanceof Number) ? ((Number) idObj).longValue() : Long.valueOf(String.valueOf(idObj));
|
||||||
|
Map<String,Object> reply = jdbcTemplate.query(
|
||||||
|
"SELECT content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> r = new HashMap<>();
|
||||||
|
r.put("content", rs.getString("content"));
|
||||||
|
r.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, consultId
|
||||||
|
);
|
||||||
|
latest.put("replied", Objects.equals("resolved", String.valueOf(latest.get("status"))));
|
||||||
|
if (reply != null) {
|
||||||
|
latest.put("latestReply", reply.get("content"));
|
||||||
|
latest.put("latestReplyAt", reply.get("createdAt"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容:GET /api/consults 等同于 /api/consults/latest
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> latestAlias(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
return latest(shopId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户确认已读:查看过管理员回复后,将状态回到 open
|
||||||
|
@PutMapping("/{id}/ack")
|
||||||
|
public ResponseEntity<?> ack(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
// 若该咨询属于该用户,则把状态改回 open(仅在当前为 resolved 时)
|
||||||
|
int updated = jdbcTemplate.update(
|
||||||
|
"UPDATE consults SET status='open', updated_at=NOW() WHERE id=? AND user_id=? AND status='resolved'",
|
||||||
|
id, (userId == null ? defaults.getUserId() : userId));
|
||||||
|
Map<String,Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("updated", updated);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Map<String,Object> consult = jdbcTemplate.query(
|
||||||
|
"SELECT id, shop_id AS shopId, user_id AS userId, topic, message, status, created_at FROM consults WHERE id=?",
|
||||||
|
rs -> {
|
||||||
|
if (rs.next()) {
|
||||||
|
Map<String,Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("id", rs.getLong("id"));
|
||||||
|
m.put("shopId", rs.getLong("shopId"));
|
||||||
|
m.put("userId", rs.getLong("userId"));
|
||||||
|
m.put("topic", rs.getString("topic"));
|
||||||
|
m.put("message", rs.getString("message"));
|
||||||
|
m.put("status", rs.getString("status"));
|
||||||
|
m.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, id);
|
||||||
|
if (consult == null) return ResponseEntity.notFound().build();
|
||||||
|
if (userId != null) {
|
||||||
|
Object ownerObj = consult.get("userId");
|
||||||
|
Long ownerId = (ownerObj instanceof Number) ? ((Number) ownerObj).longValue() : Long.valueOf(String.valueOf(ownerObj));
|
||||||
|
if (!Objects.equals(ownerId, userId)) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("message", "forbidden"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<Map<String,Object>> replies = jdbcTemplate.query(
|
||||||
|
"SELECT id, user_id AS userId, content, created_at FROM consult_replies WHERE consult_id=? ORDER BY id ASC",
|
||||||
|
(rs, i) -> {
|
||||||
|
Map<String,Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", rs.getLong("id"));
|
||||||
|
r.put("userId", rs.getLong("userId"));
|
||||||
|
r.put("content", rs.getString("content"));
|
||||||
|
r.put("createdAt", rs.getTimestamp("created_at"));
|
||||||
|
return r;
|
||||||
|
}, id);
|
||||||
|
Map<String,Object> body = new LinkedHashMap<>();
|
||||||
|
body.putAll(consult);
|
||||||
|
body.put("replies", replies);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.example.demo.customer.controller;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.customer.dto.CustomerDtos;
|
||||||
|
import com.example.demo.customer.entity.Customer;
|
||||||
|
import com.example.demo.customer.service.CustomerService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/customers")
|
||||||
|
public class CustomerController {
|
||||||
|
|
||||||
|
private final CustomerService customerService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public CustomerController(CustomerService customerService, AppDefaultsProperties defaults) {
|
||||||
|
this.customerService = customerService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "debtOnly", required = false, defaultValue = "false") boolean debtOnly,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "50") int size) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
return ResponseEntity.ok(customerService.search(sid, kw, debtOnly, Math.max(0, page-1), size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
|
||||||
|
java.util.Optional<Customer> oc = customerService.findById(id);
|
||||||
|
if (oc.isEmpty()) return ResponseEntity.notFound().build();
|
||||||
|
Customer c = oc.get();
|
||||||
|
java.util.Map<String,Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("id", c.getId());
|
||||||
|
body.put("name", c.getName());
|
||||||
|
body.put("contactName", c.getContactName());
|
||||||
|
body.put("mobile", c.getMobile());
|
||||||
|
body.put("phone", c.getPhone());
|
||||||
|
body.put("priceLevel", c.getPriceLevel());
|
||||||
|
body.put("remark", c.getRemark());
|
||||||
|
body.put("address", c.getAddress());
|
||||||
|
body.put("arOpening", c.getArOpening());
|
||||||
|
Long sid = (shopId == null ? null : shopId);
|
||||||
|
if (sid != null) {
|
||||||
|
body.put("receivable", customerService.computeReceivable(sid, id));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
Long id = customerService.create(sid, uid, req);
|
||||||
|
java.util.Map<String,Object> body = new java.util.HashMap<>(); body.put("id", id); return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
customerService.update(id, sid, uid, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.demo.customer.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public class CustomerDtos {
|
||||||
|
|
||||||
|
public static class CustomerListItem {
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String contactName;
|
||||||
|
public String mobile;
|
||||||
|
public String phone;
|
||||||
|
public String priceLevel;
|
||||||
|
public String remark;
|
||||||
|
public BigDecimal receivable;
|
||||||
|
public CustomerListItem() {}
|
||||||
|
public CustomerListItem(Long id, String name, String contactName, String mobile, String phone, String priceLevel, String remark, BigDecimal receivable) {
|
||||||
|
this.id = id; this.name = name; this.contactName = contactName; this.mobile = mobile; this.phone = phone; this.priceLevel = priceLevel; this.remark = remark; this.receivable = receivable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateOrUpdateCustomerRequest {
|
||||||
|
public String name;
|
||||||
|
public String priceLevel;
|
||||||
|
public String contactName;
|
||||||
|
public String mobile;
|
||||||
|
public String phone;
|
||||||
|
public String address;
|
||||||
|
public java.math.BigDecimal arOpening;
|
||||||
|
public String remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.example.demo.customer.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "customers")
|
||||||
|
public class Customer {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "phone", length = 32)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(name = "mobile", length = 32)
|
||||||
|
private String mobile;
|
||||||
|
|
||||||
|
@Column(name = "address", length = 255)
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
@Column(name = "contact_name", length = 64)
|
||||||
|
private String contactName;
|
||||||
|
|
||||||
|
@Column(name = "price_level", nullable = false, length = 32)
|
||||||
|
private String priceLevel;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Column(name = "ar_opening", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal arOpening;
|
||||||
|
|
||||||
|
@Column(name = "remark", length = 255)
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
public String getMobile() { return mobile; }
|
||||||
|
public void setMobile(String mobile) { this.mobile = mobile; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public void setAddress(String address) { this.address = address; }
|
||||||
|
public String getContactName() { return contactName; }
|
||||||
|
public void setContactName(String contactName) { this.contactName = contactName; }
|
||||||
|
public String getPriceLevel() { return priceLevel; }
|
||||||
|
public void setPriceLevel(String priceLevel) { this.priceLevel = priceLevel; }
|
||||||
|
public Integer getStatus() { return status; }
|
||||||
|
public void setStatus(Integer status) { this.status = status; }
|
||||||
|
public BigDecimal getArOpening() { return arOpening; }
|
||||||
|
public void setArOpening(BigDecimal arOpening) { this.arOpening = arOpening; }
|
||||||
|
public String getRemark() { return remark; }
|
||||||
|
public void setRemark(String remark) { this.remark = remark; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.example.demo.customer.repo;
|
||||||
|
|
||||||
|
import com.example.demo.customer.entity.Customer;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CustomerRepository extends JpaRepository<Customer, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT c FROM Customer c WHERE c.shopId=:shopId AND c.deletedAt IS NULL AND ( :kw IS NULL OR c.name LIKE CONCAT('%',:kw,'%') OR c.mobile LIKE CONCAT('%',:kw,'%') OR c.phone LIKE CONCAT('%',:kw,'%')) ORDER BY c.id DESC")
|
||||||
|
List<Customer> search(@Param("shopId") Long shopId, @Param("kw") String kw, org.springframework.data.domain.Pageable pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.example.demo.customer.service;
|
||||||
|
|
||||||
|
import com.example.demo.customer.dto.CustomerDtos;
|
||||||
|
import com.example.demo.customer.entity.Customer;
|
||||||
|
import com.example.demo.customer.repo.CustomerRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CustomerService {
|
||||||
|
|
||||||
|
private final CustomerRepository customerRepository;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public CustomerService(CustomerRepository customerRepository, JdbcTemplate jdbcTemplate) {
|
||||||
|
this.customerRepository = customerRepository;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Map<String, Object> search(Long shopId, String kw, boolean debtOnly, int page, int size) {
|
||||||
|
List<Customer> list = customerRepository.search(shopId, kw, PageRequest.of(page, size));
|
||||||
|
List<CustomerDtos.CustomerListItem> items = list.stream().map(c -> new CustomerDtos.CustomerListItem(
|
||||||
|
c.getId(), c.getName(), c.getContactName(), c.getMobile(), c.getPhone(), c.getPriceLevel(), c.getRemark(), calcReceivable(shopId, c.getId(), c.getArOpening())
|
||||||
|
)).collect(Collectors.toList());
|
||||||
|
if (debtOnly) {
|
||||||
|
items = items.stream().filter(it -> it.receivable != null && it.receivable.compareTo(BigDecimal.ZERO) > 0).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
java.util.Map<String, Object> resp = new java.util.HashMap<>();
|
||||||
|
resp.put("list", items);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Customer> findById(Long id) {
|
||||||
|
return customerRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long create(Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
|
Customer c = new Customer();
|
||||||
|
c.setShopId(shopId); c.setUserId(userId);
|
||||||
|
c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
||||||
|
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
||||||
|
c.setStatus(1);
|
||||||
|
c.setArOpening(req.arOpening == null ? BigDecimal.ZERO : req.arOpening);
|
||||||
|
c.setRemark(req.remark);
|
||||||
|
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||||
|
c.setCreatedAt(now);
|
||||||
|
c.setUpdatedAt(now);
|
||||||
|
return customerRepository.save(c).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void update(Long id, Long shopId, Long userId, CustomerDtos.CreateOrUpdateCustomerRequest req) {
|
||||||
|
Customer c = customerRepository.findById(id).orElseThrow();
|
||||||
|
if (!c.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺修改");
|
||||||
|
c.setName(req.name); c.setPriceLevel(cnPriceLevelOrDefault(req.priceLevel));
|
||||||
|
c.setContactName(req.contactName); c.setMobile(req.mobile); c.setPhone(req.phone); c.setAddress(req.address);
|
||||||
|
if (req.arOpening != null) c.setArOpening(req.arOpening);
|
||||||
|
c.setRemark(req.remark);
|
||||||
|
c.setUpdatedAt(java.time.LocalDateTime.now());
|
||||||
|
customerRepository.save(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String cnPriceLevelOrDefault(String v) {
|
||||||
|
if (v == null || v.isBlank()) return "零售价";
|
||||||
|
// 兼容历史英文值
|
||||||
|
if ("retail".equalsIgnoreCase(v)) return "零售价";
|
||||||
|
if ("wholesale".equalsIgnoreCase(v) || "distribution".equalsIgnoreCase(v)) return "批发价";
|
||||||
|
if ("big_client".equalsIgnoreCase(v)) return "大单报价";
|
||||||
|
return v; // 已是中文
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal computeReceivable(Long shopId, Long customerId) {
|
||||||
|
Optional<Customer> oc = customerRepository.findById(customerId);
|
||||||
|
BigDecimal opening = oc.map(Customer::getArOpening).orElse(BigDecimal.ZERO);
|
||||||
|
return calcReceivable(shopId, customerId, opening);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal calcReceivable(Long shopId, Long customerId, BigDecimal opening) {
|
||||||
|
BigDecimal open = opening == null ? BigDecimal.ZERO : opening;
|
||||||
|
BigDecimal sale = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount - paid_amount),0) FROM sales_orders WHERE shop_id=? AND customer_id=? AND status='approved'", BigDecimal.class, shopId, customerId));
|
||||||
|
BigDecimal saleRet = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount - paid_amount),0) FROM sales_return_orders WHERE shop_id=? AND customer_id=? AND status='approved'", BigDecimal.class, shopId, customerId));
|
||||||
|
BigDecimal otherIn = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM other_transactions WHERE shop_id=? AND counterparty_type='customer' AND counterparty_id=? AND `type`='income'", BigDecimal.class, shopId, customerId));
|
||||||
|
BigDecimal otherOut = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM other_transactions WHERE shop_id=? AND counterparty_type='customer' AND counterparty_id=? AND `type`='expense'", BigDecimal.class, shopId, customerId));
|
||||||
|
return open.add(sale).subtract(saleRet).add(otherIn).subtract(otherOut).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal n(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.example.demo.dashboard;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/dashboard")
|
||||||
|
public class DashboardController {
|
||||||
|
|
||||||
|
private final DashboardService dashboardService;
|
||||||
|
|
||||||
|
public DashboardController(DashboardService dashboardService) {
|
||||||
|
this.dashboardService = dashboardService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/overview")
|
||||||
|
public ResponseEntity<DashboardOverviewResponse> overview(
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId
|
||||||
|
) {
|
||||||
|
return ResponseEntity.ok(dashboardService.getOverviewByUserOrShop(userId, shopId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.example.demo.dashboard;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public class DashboardOverviewResponse {
|
||||||
|
private BigDecimal todaySalesAmount;
|
||||||
|
private BigDecimal monthSalesAmount;
|
||||||
|
private BigDecimal monthGrossProfit;
|
||||||
|
private BigDecimal stockTotalQuantity;
|
||||||
|
|
||||||
|
public DashboardOverviewResponse(BigDecimal todaySalesAmount, BigDecimal monthSalesAmount, BigDecimal monthGrossProfit, BigDecimal stockTotalQuantity) {
|
||||||
|
this.todaySalesAmount = todaySalesAmount;
|
||||||
|
this.monthSalesAmount = monthSalesAmount;
|
||||||
|
this.monthGrossProfit = monthGrossProfit;
|
||||||
|
this.stockTotalQuantity = stockTotalQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getTodaySalesAmount() {
|
||||||
|
return todaySalesAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMonthGrossProfit() {
|
||||||
|
return monthGrossProfit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getStockTotalQuantity() {
|
||||||
|
return stockTotalQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getMonthSalesAmount() {
|
||||||
|
return monthSalesAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.example.demo.dashboard;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class DashboardRepository {
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
public BigDecimal sumTodaySalesOrders(Long shopId) {
|
||||||
|
Object result = entityManager.createNativeQuery(
|
||||||
|
"SELECT COALESCE((SELECT SUM(amount) FROM sales_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=CURRENT_DATE() AND order_time<CURRENT_DATE()+INTERVAL 1 DAY),0) - " +
|
||||||
|
"COALESCE((SELECT SUM(amount) FROM sales_return_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=CURRENT_DATE() AND order_time<CURRENT_DATE()+INTERVAL 1 DAY),0)"
|
||||||
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
|
return toBigDecimal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal sumMonthGrossProfitApprox(Long shopId) {
|
||||||
|
Object result = entityManager.createNativeQuery(
|
||||||
|
"SELECT COALESCE((SELECT SUM(soi.amount - soi.quantity * COALESCE(pp.purchase_price,0)) FROM sales_orders so " +
|
||||||
|
"JOIN sales_order_items soi ON soi.order_id=so.id LEFT JOIN product_prices pp ON pp.product_id=soi.product_id AND pp.shop_id=so.shop_id " +
|
||||||
|
"WHERE so.shop_id=:shopId AND so.status='approved' AND so.order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND so.order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0) - " +
|
||||||
|
"COALESCE((SELECT SUM(sroi.amount) FROM sales_return_orders sro JOIN sales_return_order_items sroi ON sroi.order_id=sro.id " +
|
||||||
|
"WHERE sro.shop_id=:shopId AND sro.status='approved' AND sro.order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND sro.order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0)"
|
||||||
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
|
return toBigDecimal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal sumMonthSalesOrders(Long shopId) {
|
||||||
|
Object result = entityManager.createNativeQuery(
|
||||||
|
"SELECT COALESCE((SELECT SUM(amount) FROM sales_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0) - " +
|
||||||
|
"COALESCE((SELECT SUM(amount) FROM sales_return_orders WHERE shop_id=:shopId AND status='approved' AND order_time>=DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') AND order_time<DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)),0)"
|
||||||
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
|
return toBigDecimal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal sumTotalInventoryQty(Long shopId) {
|
||||||
|
Object result = entityManager.createNativeQuery(
|
||||||
|
"SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE shop_id = :shopId"
|
||||||
|
).setParameter("shopId", shopId).getSingleResult();
|
||||||
|
return toBigDecimal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal toBigDecimal(Object value) {
|
||||||
|
if (value == null) return BigDecimal.ZERO;
|
||||||
|
if (value instanceof BigDecimal) return (BigDecimal) value;
|
||||||
|
if (value instanceof Number) return BigDecimal.valueOf(((Number) value).doubleValue());
|
||||||
|
try {
|
||||||
|
return new BigDecimal(value.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long findShopIdByUserId(Long userId) {
|
||||||
|
if (userId == null) return null;
|
||||||
|
Object result = entityManager.createNativeQuery(
|
||||||
|
"SELECT shop_id FROM users WHERE id = :userId LIMIT 1"
|
||||||
|
).setParameter("userId", userId).getSingleResult();
|
||||||
|
if (result == null) return null;
|
||||||
|
if (result instanceof Number) return ((Number) result).longValue();
|
||||||
|
try {
|
||||||
|
return Long.valueOf(result.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.demo.dashboard;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DashboardService {
|
||||||
|
private final DashboardRepository dashboardRepository;
|
||||||
|
|
||||||
|
public DashboardService(DashboardRepository dashboardRepository) {
|
||||||
|
this.dashboardRepository = dashboardRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardOverviewResponse getOverview(long shopId) {
|
||||||
|
BigDecimal todaySales = dashboardRepository.sumTodaySalesOrders(shopId);
|
||||||
|
BigDecimal monthSales = dashboardRepository.sumMonthSalesOrders(shopId);
|
||||||
|
BigDecimal monthGrossProfit = dashboardRepository.sumMonthGrossProfitApprox(shopId);
|
||||||
|
BigDecimal stockTotalQty = dashboardRepository.sumTotalInventoryQty(shopId);
|
||||||
|
return new DashboardOverviewResponse(todaySales, monthSales, monthGrossProfit, stockTotalQty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardOverviewResponse getOverviewByUserOrShop(Long userId, Long shopIdOrNull) {
|
||||||
|
Long resolvedShopId = null;
|
||||||
|
if (userId != null) {
|
||||||
|
resolvedShopId = dashboardRepository.findShopIdByUserId(userId);
|
||||||
|
}
|
||||||
|
if (resolvedShopId == null && shopIdOrNull != null) {
|
||||||
|
resolvedShopId = shopIdOrNull;
|
||||||
|
}
|
||||||
|
if (resolvedShopId == null) {
|
||||||
|
resolvedShopId = 1L;
|
||||||
|
}
|
||||||
|
return getOverview(resolvedShopId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "notices")
|
||||||
|
public class Notice {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, length = 120)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "content", nullable = false, length = 500)
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "tag", length = 32)
|
||||||
|
private String tag;
|
||||||
|
|
||||||
|
@Column(name = "is_pinned", nullable = false)
|
||||||
|
private Boolean pinned = false;
|
||||||
|
|
||||||
|
@Column(name = "starts_at")
|
||||||
|
private LocalDateTime startsAt;
|
||||||
|
|
||||||
|
@Column(name = "ends_at")
|
||||||
|
private LocalDateTime endsAt;
|
||||||
|
|
||||||
|
@Convert(converter = NoticeStatusConverter.class)
|
||||||
|
@Column(name = "status", nullable = false, length = 16)
|
||||||
|
private NoticeStatus status = NoticeStatus.PUBLISHED;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
public String getTag() { return tag; }
|
||||||
|
public void setTag(String tag) { this.tag = tag; }
|
||||||
|
public Boolean getPinned() { return pinned; }
|
||||||
|
public void setPinned(Boolean pinned) { this.pinned = pinned; }
|
||||||
|
public LocalDateTime getStartsAt() { return startsAt; }
|
||||||
|
public void setStartsAt(LocalDateTime startsAt) { this.startsAt = startsAt; }
|
||||||
|
public LocalDateTime getEndsAt() { return endsAt; }
|
||||||
|
public void setEndsAt(LocalDateTime endsAt) { this.endsAt = endsAt; }
|
||||||
|
public NoticeStatus getStatus() { return status; }
|
||||||
|
public void setStatus(NoticeStatus status) { this.status = status; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/notices")
|
||||||
|
public class NoticeController {
|
||||||
|
|
||||||
|
private final NoticeService noticeService;
|
||||||
|
|
||||||
|
public NoticeController(NoticeService noticeService) {
|
||||||
|
this.noticeService = noticeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<Notice>> list() {
|
||||||
|
return ResponseEntity.ok(noticeService.listActive());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface NoticeRepository extends JpaRepository<Notice, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT n FROM Notice n WHERE n.status = :status " +
|
||||||
|
"AND (n.startsAt IS NULL OR n.startsAt <= CURRENT_TIMESTAMP) AND (n.endsAt IS NULL OR n.endsAt >= CURRENT_TIMESTAMP) " +
|
||||||
|
"ORDER BY n.pinned DESC, n.createdAt DESC")
|
||||||
|
List<Notice> findActiveNotices(@Param("status") NoticeStatus status);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NoticeService {
|
||||||
|
private final NoticeRepository noticeRepository;
|
||||||
|
|
||||||
|
public NoticeService(NoticeRepository noticeRepository) {
|
||||||
|
this.noticeRepository = noticeRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Notice> listActive() {
|
||||||
|
return noticeRepository.findActiveNotices(NoticeStatus.PUBLISHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告状态。
|
||||||
|
*/
|
||||||
|
public enum NoticeStatus {
|
||||||
|
DRAFT,
|
||||||
|
PUBLISHED,
|
||||||
|
OFFLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.example.demo.notice;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
public class NoticeStatusConverter implements AttributeConverter<NoticeStatus, String> {
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(NoticeStatus attribute) {
|
||||||
|
if (attribute == null) return null;
|
||||||
|
switch (attribute) {
|
||||||
|
case DRAFT:
|
||||||
|
return "draft";
|
||||||
|
case PUBLISHED:
|
||||||
|
return "published";
|
||||||
|
case OFFLINE:
|
||||||
|
return "offline";
|
||||||
|
default:
|
||||||
|
return "published";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public NoticeStatus convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null) return null;
|
||||||
|
switch (dbData) {
|
||||||
|
case "draft":
|
||||||
|
return NoticeStatus.DRAFT;
|
||||||
|
case "published":
|
||||||
|
return NoticeStatus.PUBLISHED;
|
||||||
|
case "offline":
|
||||||
|
return NoticeStatus.OFFLINE;
|
||||||
|
default:
|
||||||
|
return NoticeStatus.PUBLISHED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.example.demo.order;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.order.dto.OrderDtos;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class OrderController {
|
||||||
|
|
||||||
|
private final OrderService orderService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public OrderController(OrderService orderService, AppDefaultsProperties defaults) {
|
||||||
|
this.orderService = orderService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/orders")
|
||||||
|
public ResponseEntity<?> createOrder(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody OrderDtos.CreateOrderRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.create(sid, uid, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/payments/{biz}")
|
||||||
|
public ResponseEntity<?> createPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@PathVariable("biz") String biz,
|
||||||
|
@RequestBody java.util.List<OrderDtos.PaymentItem> req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.createPayments(sid, uid, req, biz));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/orders/{id}/void")
|
||||||
|
public ResponseEntity<?> voidOrder(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@PathVariable("id") Long id,
|
||||||
|
@RequestParam("type") String type) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
orderService.voidOrder(sid, uid, id, type);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/orders")
|
||||||
|
public ResponseEntity<?> list(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "biz", required = false) String biz,
|
||||||
|
@RequestParam(name = "type", required = false) String type,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.list(sid, uid, biz, type, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/orders/{id}")
|
||||||
|
public ResponseEntity<?> getOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@PathVariable("id") Long id) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
return ResponseEntity.ok(orderService.getSalesOrderDetail(sid, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容前端直接调用 /api/purchase-orders
|
||||||
|
@GetMapping("/purchase-orders")
|
||||||
|
public ResponseEntity<?> purchaseOrders(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
String type = ("returned".equalsIgnoreCase(status) ? "purchase.return" : "purchase.in");
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.list(sid, uid, "purchase", type, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/purchase-orders/{id}")
|
||||||
|
public ResponseEntity<?> getPurchaseOrderDetail(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@PathVariable("id") Long id) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
return ResponseEntity.ok(orderService.getPurchaseOrderDetail(sid, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/payments")
|
||||||
|
public ResponseEntity<?> listPayments(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "direction", required = false) String direction,
|
||||||
|
@RequestParam(name = "bizType", required = false) String bizType,
|
||||||
|
@RequestParam(name = "accountId", required = false) Long accountId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listPayments(sid, uid, direction, bizType, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/other-transactions")
|
||||||
|
public ResponseEntity<?> listOtherTransactions(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "type", required = false) String type,
|
||||||
|
@RequestParam(name = "accountId", required = false) Long accountId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listOtherTransactions(sid, uid, type, accountId, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/other-transactions")
|
||||||
|
public ResponseEntity<?> createOtherTransaction(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody OrderDtos.CreateOtherTransactionRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.createOtherTransaction(sid, uid, req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/inventories/logs")
|
||||||
|
public ResponseEntity<?> listInventoryLogs(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "productId", required = false) Long productId,
|
||||||
|
@RequestParam(name = "reason", required = false) String reason,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size,
|
||||||
|
@RequestParam(name = "startDate", required = false) String startDate,
|
||||||
|
@RequestParam(name = "endDate", required = false) String endDate) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
return ResponseEntity.ok(orderService.listInventoryLogs(sid, uid, productId, reason, kw, Math.max(0, page-1), size, startDate, endDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.demo.order;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的进程内单号生成器:前缀 + yyyyMMdd + 4位流水
|
||||||
|
* 说明:演示用,生产建议使用数据库序列或 Redis 自增确保多实例唯一。
|
||||||
|
*/
|
||||||
|
public class OrderNumberGenerator {
|
||||||
|
private static final ConcurrentHashMap<String, AtomicInteger> dateCounters = new ConcurrentHashMap<>();
|
||||||
|
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||||
|
|
||||||
|
public static String next(String prefix) {
|
||||||
|
String day = LocalDateTime.now().format(DATE);
|
||||||
|
String key = prefix + day;
|
||||||
|
int seq = dateCounters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet();
|
||||||
|
return prefix + day + String.format("%04d", seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
package com.example.demo.order;
|
||||||
|
|
||||||
|
import com.example.demo.common.AccountDefaultsProperties;
|
||||||
|
import com.example.demo.order.dto.OrderDtos;
|
||||||
|
import com.example.demo.product.entity.Inventory;
|
||||||
|
import com.example.demo.product.entity.ProductPrice;
|
||||||
|
import com.example.demo.product.repo.InventoryRepository;
|
||||||
|
import com.example.demo.product.repo.ProductPriceRepository;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final InventoryRepository inventoryRepository;
|
||||||
|
private final AccountDefaultsProperties accountDefaults;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ProductPriceRepository productPriceRepository;
|
||||||
|
|
||||||
|
private final com.example.demo.common.AppDefaultsProperties appDefaults;
|
||||||
|
|
||||||
|
public OrderService(InventoryRepository inventoryRepository,
|
||||||
|
JdbcTemplate jdbcTemplate,
|
||||||
|
AccountDefaultsProperties accountDefaults,
|
||||||
|
ProductPriceRepository productPriceRepository,
|
||||||
|
com.example.demo.common.AppDefaultsProperties appDefaults) {
|
||||||
|
this.inventoryRepository = inventoryRepository;
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.accountDefaults = accountDefaults;
|
||||||
|
this.productPriceRepository = productPriceRepository;
|
||||||
|
this.appDefaults = appDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Object create(Long shopId, Long userId, OrderDtos.CreateOrderRequest req) {
|
||||||
|
String type = req.type == null ? "" : req.type;
|
||||||
|
boolean isSaleOut = "sale.out".equals(type) || "out".equals(type) || "sale".equals(type);
|
||||||
|
boolean isPurchaseIn = "purchase.in".equals(type) || "in".equals(type);
|
||||||
|
boolean isSaleReturn = "sale.return".equals(type);
|
||||||
|
boolean isPurchaseReturn = "purchase.return".equals(type);
|
||||||
|
boolean isSaleCollect = "sale.collect".equals(type);
|
||||||
|
boolean isPurchasePay = "purchase.pay".equals(type);
|
||||||
|
|
||||||
|
if (isSaleCollect || isPurchasePay) {
|
||||||
|
java.util.List<OrderDtos.PaymentItem> payments = req.payments == null ? java.util.List.of() : req.payments;
|
||||||
|
return createPayments(shopId, userId, payments, isSaleCollect ? "sale" : "purchase");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(isSaleOut || isPurchaseIn || isSaleReturn || isPurchaseReturn)) throw new IllegalArgumentException("不支持的type");
|
||||||
|
if (req.items == null || req.items.isEmpty()) throw new IllegalArgumentException("明细为空");
|
||||||
|
|
||||||
|
// 后端重算金额
|
||||||
|
final BigDecimal[] totalRef = new BigDecimal[]{BigDecimal.ZERO};
|
||||||
|
for (OrderDtos.Item it : req.items) {
|
||||||
|
BigDecimal qty = n(it.quantity);
|
||||||
|
BigDecimal price = n(it.unitPrice);
|
||||||
|
BigDecimal dr = n(it.discountRate);
|
||||||
|
BigDecimal line = qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100"))));
|
||||||
|
totalRef[0] = totalRef[0].add(scale2(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预取成本价格(仅销售出库/退货需要)
|
||||||
|
Map<Long, BigDecimal> costPriceCache = new HashMap<>();
|
||||||
|
if (isSaleOut || isSaleReturn) {
|
||||||
|
for (OrderDtos.Item it : req.items) {
|
||||||
|
Long pid = it.productId;
|
||||||
|
if (pid == null || costPriceCache.containsKey(pid)) continue;
|
||||||
|
costPriceCache.put(pid, resolveProductCostPrice(pid, shopId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 库存变动(保存即 approved)
|
||||||
|
LocalDateTime now = nowUtc();
|
||||||
|
for (OrderDtos.Item it : req.items) {
|
||||||
|
Long pid = it.productId;
|
||||||
|
Inventory inv = inventoryRepository.findById(pid).orElseGet(Inventory::new);
|
||||||
|
inv.setProductId(pid);
|
||||||
|
inv.setShopId(shopId);
|
||||||
|
inv.setUserId(userId);
|
||||||
|
BigDecimal cur = n(inv.getQuantity());
|
||||||
|
BigDecimal delta = BigDecimal.ZERO;
|
||||||
|
if (isSaleOut) delta = n(it.quantity).negate();
|
||||||
|
if (isPurchaseIn) delta = n(it.quantity);
|
||||||
|
if (isSaleReturn) delta = n(it.quantity); // 退货入库
|
||||||
|
if (isPurchaseReturn) delta = n(it.quantity).negate(); // 退货出库
|
||||||
|
BigDecimal next = cur.add(delta);
|
||||||
|
if (isSaleOut && next.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
throw new IllegalStateException("库存不足");
|
||||||
|
}
|
||||||
|
inv.setQuantity(next);
|
||||||
|
inv.setUpdatedAt(now);
|
||||||
|
inventoryRepository.save(inv);
|
||||||
|
|
||||||
|
// 写入库存流水(可选金额)
|
||||||
|
String imSql = "INSERT INTO inventory_movements (shop_id,user_id,product_id,source_type,source_id,qty_delta,amount_delta,cost_price,cost_amount,reason,tx_time,remark,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NOW())";
|
||||||
|
String sourceType = isSaleOut ? "sale" : (isPurchaseIn ? "purchase" : (isSaleReturn ? "sale_return" : "purchase_return"));
|
||||||
|
BigDecimal costPrice = null;
|
||||||
|
BigDecimal costAmount = null;
|
||||||
|
if (isSaleOut || isSaleReturn) {
|
||||||
|
costPrice = costPriceCache.getOrDefault(pid, BigDecimal.ZERO);
|
||||||
|
costAmount = scale2(costPrice.multiply(delta));
|
||||||
|
}
|
||||||
|
jdbcTemplate.update(imSql, shopId, userId, pid, sourceType, null, delta, null, costPrice, costAmount,
|
||||||
|
null, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant()), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String prefix = isSaleOut || isSaleReturn ? (isSaleReturn ? "SR" : "SO") : (isPurchaseReturn ? "PR" : "PO");
|
||||||
|
String orderNo = OrderNumberGenerator.next(prefix);
|
||||||
|
|
||||||
|
// 持久化订单头与明细(简化使用 JDBC)
|
||||||
|
String headTable = isSaleOut ? "sales_orders" : (isPurchaseIn ? "purchase_orders" : (isSaleReturn ? "sales_return_orders" : "purchase_return_orders"));
|
||||||
|
String itemTable = isSaleOut ? "sales_order_items" : (isPurchaseIn ? "purchase_order_items" : (isSaleReturn ? "sales_return_order_items" : "purchase_return_order_items"));
|
||||||
|
|
||||||
|
// insert head(按表结构分别处理 sales*/purchase* 的 customer_id/supplier_id 列)
|
||||||
|
KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
boolean isSalesHead = headTable.startsWith("sales");
|
||||||
|
boolean isPurchaseHead = headTable.startsWith("purchase");
|
||||||
|
String headSql;
|
||||||
|
if (isSalesHead) {
|
||||||
|
headSql = "INSERT INTO " + headTable + " (shop_id,user_id,customer_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,?, 'approved', ?, 0, ?, NOW(), NOW())";
|
||||||
|
} else if (isPurchaseHead) {
|
||||||
|
headSql = "INSERT INTO " + headTable + " (shop_id,user_id,supplier_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,?, 'approved', ?, 0, ?, NOW(), NOW())";
|
||||||
|
} else {
|
||||||
|
// 理论不会到这里,兜底为 sales 结构
|
||||||
|
headSql = "INSERT INTO " + headTable + " (shop_id,user_id,order_no,order_time,status,amount,paid_amount,remark,created_at,updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,'approved', ?, 0, ?, NOW(), NOW())";
|
||||||
|
}
|
||||||
|
|
||||||
|
final Long customerId = (isSalesHead && req.customerId == null)
|
||||||
|
? resolveOrCreateDefaultCustomer(shopId, userId)
|
||||||
|
: req.customerId;
|
||||||
|
final Long supplierId = (isPurchaseHead && req.supplierId == null)
|
||||||
|
? resolveOrCreateDefaultSupplier(shopId, userId)
|
||||||
|
: req.supplierId;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
java.sql.PreparedStatement ps = con.prepareStatement(headSql, new String[]{"id"});
|
||||||
|
int idx = 1;
|
||||||
|
ps.setLong(idx++, shopId);
|
||||||
|
ps.setLong(idx++, userId);
|
||||||
|
if (isSalesHead) {
|
||||||
|
ps.setObject(idx++, customerId, java.sql.Types.BIGINT);
|
||||||
|
} else if (isPurchaseHead) {
|
||||||
|
ps.setObject(idx++, supplierId, java.sql.Types.BIGINT);
|
||||||
|
}
|
||||||
|
ps.setString(idx++, orderNo);
|
||||||
|
ps.setTimestamp(idx++, java.sql.Timestamp.from(now.atZone(java.time.ZoneOffset.UTC).toInstant()));
|
||||||
|
ps.setBigDecimal(idx++, scale2(totalRef[0]));
|
||||||
|
ps.setString(idx, req.remark);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
Number orderKey = kh.getKey();
|
||||||
|
Long orderId = (orderKey == null ? null : orderKey.longValue());
|
||||||
|
|
||||||
|
// insert items(销售类有折扣列,采购类无折扣列)
|
||||||
|
boolean itemsHasDiscount = isSaleOut || isSaleReturn;
|
||||||
|
String itemSql = itemsHasDiscount
|
||||||
|
? ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,discount_rate,cost_price,cost_amount,amount) VALUES (?,?,?,?,?,?,?,?)")
|
||||||
|
: ("INSERT INTO " + itemTable + " (order_id,product_id,quantity,unit_price,amount) VALUES (?,?,?,?,?)");
|
||||||
|
for (OrderDtos.Item it : req.items) {
|
||||||
|
BigDecimal qty = n(it.quantity);
|
||||||
|
BigDecimal price = n(it.unitPrice);
|
||||||
|
BigDecimal dr = n(it.discountRate);
|
||||||
|
BigDecimal line = scale2(qty.multiply(price).multiply(BigDecimal.ONE.subtract(dr.divide(new BigDecimal("100")))));
|
||||||
|
BigDecimal costPrice = BigDecimal.ZERO;
|
||||||
|
BigDecimal costAmount = BigDecimal.ZERO;
|
||||||
|
if (itemsHasDiscount) {
|
||||||
|
costPrice = costPriceCache.getOrDefault(it.productId, BigDecimal.ZERO);
|
||||||
|
costAmount = scale2(qty.multiply(costPrice));
|
||||||
|
}
|
||||||
|
if (itemsHasDiscount) {
|
||||||
|
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, dr, costPrice, costAmount, line);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(itemSql, orderId, it.productId, qty, price, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维护供应商应付:采购入库增加,应付=+amount;采购退货减少,应付=-amount
|
||||||
|
if (isPurchaseHead && supplierId != null) {
|
||||||
|
java.math.BigDecimal delta = scale2(totalRef[0]);
|
||||||
|
if (isPurchaseReturn) delta = delta.negate();
|
||||||
|
adjustSupplierPayable(supplierId, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderDtos.CreateOrderResponse(orderId, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveOrCreateDefaultCustomer(Long shopId, Long userId) {
|
||||||
|
String name = appDefaults.getCustomerName();
|
||||||
|
java.util.List<Long> ids = jdbcTemplate.query("SELECT id FROM customers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!ids.isEmpty()) return ids.get(0);
|
||||||
|
jdbcTemplate.update("INSERT INTO customers (shop_id,user_id,name,price_level,status,created_at,updated_at) VALUES (?,?,?,'retail',1,NOW(),NOW())",
|
||||||
|
shopId, userId, name);
|
||||||
|
ids = jdbcTemplate.query("SELECT id FROM customers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!ids.isEmpty()) return ids.get(0);
|
||||||
|
throw new IllegalStateException("默认客户创建失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveOrCreateDefaultSupplier(Long shopId, Long userId) {
|
||||||
|
String name = appDefaults.getSupplierName();
|
||||||
|
java.util.List<Long> ids = jdbcTemplate.query("SELECT id FROM suppliers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!ids.isEmpty()) return ids.get(0);
|
||||||
|
jdbcTemplate.update("INSERT INTO suppliers (shop_id,user_id,name,status,created_at,updated_at) VALUES (?,?,?,1,NOW(),NOW())",
|
||||||
|
shopId, userId, name);
|
||||||
|
ids = jdbcTemplate.query("SELECT id FROM suppliers WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!ids.isEmpty()) return ids.get(0);
|
||||||
|
throw new IllegalStateException("默认供应商创建失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal resolveProductCostPrice(Long productId, Long shopId) {
|
||||||
|
return productPriceRepository.findById(productId)
|
||||||
|
.filter(price -> price.getShopId().equals(shopId))
|
||||||
|
.map(ProductPrice::getPurchasePrice)
|
||||||
|
.map(OrderService::scale2)
|
||||||
|
.orElse(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public OrderDtos.CreatePaymentsResponse createPayments(Long shopId, Long userId, java.util.List<OrderDtos.PaymentItem> req, String bizType) {
|
||||||
|
ensureDefaultAccounts(shopId, userId);
|
||||||
|
List<Long> ids = new ArrayList<>();
|
||||||
|
if (req == null) return new OrderDtos.CreatePaymentsResponse(ids);
|
||||||
|
String direction = "sale".equals(bizType) ? "in" : "out";
|
||||||
|
for (OrderDtos.PaymentItem p : req) {
|
||||||
|
// 收/付款必须绑定订单(资金类不在此接口中处理)
|
||||||
|
if (("sale".equals(bizType) || "purchase".equals(bizType)) && p.orderId == null) {
|
||||||
|
throw new IllegalArgumentException("收/付款必须绑定订单");
|
||||||
|
}
|
||||||
|
Long accountId = resolveAccountId(shopId, userId, p.method);
|
||||||
|
KeyHolder kh = new GeneratedKeyHolder();
|
||||||
|
String sql = "INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,created_at) " +
|
||||||
|
"VALUES (?,?,?,?,?,?,?,NOW(),NULL,NOW())";
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"});
|
||||||
|
ps.setLong(1, shopId);
|
||||||
|
ps.setLong(2, userId);
|
||||||
|
ps.setString(3, bizType);
|
||||||
|
if (p.orderId == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, p.orderId);
|
||||||
|
ps.setLong(5, accountId);
|
||||||
|
ps.setString(6, direction);
|
||||||
|
ps.setBigDecimal(7, n(p.amount));
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
Number payKey = kh.getKey();
|
||||||
|
Long pid = (payKey == null ? null : payKey.longValue());
|
||||||
|
if (pid != null) ids.add(pid);
|
||||||
|
|
||||||
|
// 若挂单,累加已付
|
||||||
|
if (p.orderId != null) {
|
||||||
|
String table = "sale".equals(bizType) ? "sales_orders" : "purchase_orders";
|
||||||
|
jdbcTemplate.update("UPDATE " + table + " SET paid_amount = paid_amount + ? WHERE id = ?", n(p.amount), p.orderId);
|
||||||
|
|
||||||
|
// 采购付款联动应付:应付 -= 付款金额
|
||||||
|
if ("purchase".equals(bizType)) {
|
||||||
|
java.util.List<Long> sids = jdbcTemplate.query("SELECT supplier_id FROM purchase_orders WHERE id=?", (rs,rn)-> rs.getLong(1), p.orderId);
|
||||||
|
if (!sids.isEmpty() && sids.get(0) != null) {
|
||||||
|
adjustSupplierPayable(sids.get(0), n(p.amount).negate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联动账户余额:收款加,付款减
|
||||||
|
java.math.BigDecimal delta = "in".equals(direction) ? n(p.amount) : n(p.amount).negate();
|
||||||
|
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?",
|
||||||
|
scale2(delta), accountId, shopId);
|
||||||
|
}
|
||||||
|
return new OrderDtos.CreatePaymentsResponse(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void voidOrder(Long shopId, Long userId, Long id, String type) {
|
||||||
|
// type: sale.out / purchase.in / sale.return / purchase.return
|
||||||
|
String headTable;
|
||||||
|
String itemTable;
|
||||||
|
boolean revertIncrease; // true 表示作废时库存应减少(原先增加),false 表示应增加(原先减少)
|
||||||
|
if ("sale.out".equals(type)) { headTable = "sales_orders"; itemTable = "sales_order_items"; revertIncrease = false; }
|
||||||
|
else if ("purchase.in".equals(type)) { headTable = "purchase_orders"; itemTable = "purchase_order_items"; revertIncrease = true; }
|
||||||
|
else if ("sale.return".equals(type)) { headTable = "sales_return_orders"; itemTable = "sales_return_order_items"; revertIncrease = true; }
|
||||||
|
else if ("purchase.return".equals(type)) { headTable = "purchase_return_orders"; itemTable = "purchase_return_order_items"; revertIncrease = false; }
|
||||||
|
else throw new IllegalArgumentException("不支持的type");
|
||||||
|
|
||||||
|
// 查询明细
|
||||||
|
List<java.util.Map<String,Object>> rows = jdbcTemplate.queryForList("SELECT product_id, quantity FROM " + itemTable + " WHERE order_id = ?", id);
|
||||||
|
// 回滚库存
|
||||||
|
LocalDateTime now = nowUtc();
|
||||||
|
for (java.util.Map<String,Object> r : rows) {
|
||||||
|
Long pid = ((Number)r.get("product_id")).longValue();
|
||||||
|
java.math.BigDecimal qty = new java.math.BigDecimal(r.get("quantity").toString());
|
||||||
|
Inventory inv = inventoryRepository.findById(pid).orElseGet(Inventory::new);
|
||||||
|
inv.setProductId(pid);
|
||||||
|
inv.setShopId(shopId);
|
||||||
|
inv.setUserId(userId);
|
||||||
|
java.math.BigDecimal delta = revertIncrease ? qty.negate() : qty; // 与创建时相反
|
||||||
|
inv.setQuantity(n(inv.getQuantity()).add(delta));
|
||||||
|
inv.setUpdatedAt(now);
|
||||||
|
inventoryRepository.save(inv);
|
||||||
|
}
|
||||||
|
// 更新状态
|
||||||
|
jdbcTemplate.update("UPDATE " + headTable + " SET status='void' WHERE id = ?", id);
|
||||||
|
|
||||||
|
// 采购单作废回滚应付
|
||||||
|
if ("purchase.out".equals(type) || "purchase.in".equals(type) || "purchase.return".equals(type)) {
|
||||||
|
boolean purchaseIn = "purchase.in".equals(type);
|
||||||
|
boolean purchaseRet = "purchase.return".equals(type);
|
||||||
|
if (purchaseIn || purchaseRet) {
|
||||||
|
java.util.List<java.util.Map<String,Object>> h = jdbcTemplate.queryForList("SELECT supplier_id, amount FROM " + headTable + " WHERE id=?", id);
|
||||||
|
if (!h.isEmpty()) {
|
||||||
|
Long sid = ((Number)h.get(0).get("supplier_id")).longValue();
|
||||||
|
java.math.BigDecimal amt = new java.math.BigDecimal(h.get(0).get("amount").toString());
|
||||||
|
java.math.BigDecimal delta = purchaseIn ? amt.negate() : amt; // 入库作废应付减少;退货作废应付增加
|
||||||
|
adjustSupplierPayable(sid, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Map<String,Object> list(Long shopId, Long userId, String biz, String type, String kw, int page, int size, String startDate, String endDate) {
|
||||||
|
StringBuilder sql = new StringBuilder();
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
|
ps.add(shopId);
|
||||||
|
|
||||||
|
if ("purchase".equals(biz)) {
|
||||||
|
// 进货单(含退货:并入 purchase_return_orders)
|
||||||
|
sql.append("SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.amount, s.name AS supplierName, 'purchase.in' AS docType FROM purchase_orders po LEFT JOIN suppliers s ON s.id = po.supplier_id WHERE po.shop_id=?");
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (po.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "po.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND po.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND po.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
if (!("in".equalsIgnoreCase(type) || "purchase.in".equalsIgnoreCase(type))) {
|
||||||
|
// 仅退货
|
||||||
|
sql = new StringBuilder("SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?");
|
||||||
|
ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "pro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
} else {
|
||||||
|
// 合并退货
|
||||||
|
sql.append(" UNION ALL SELECT pro.id, pro.order_no AS orderNo, pro.order_time AS orderTime, pro.amount, s.name AS supplierName, 'purchase.return' AS docType FROM purchase_return_orders pro LEFT JOIN suppliers s ON s.id = pro.supplier_id WHERE pro.shop_id=?");
|
||||||
|
ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (pro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "pro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND pro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND pro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?");
|
||||||
|
} else { // 销售
|
||||||
|
sql.append("SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.amount, c.name AS customerName, 'sale.out' AS docType FROM sales_orders so LEFT JOIN customers c ON c.id = so.customer_id WHERE so.shop_id=?");
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (so.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "so.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND so.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND so.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
if (!("out".equalsIgnoreCase(type) || "sale.out".equalsIgnoreCase(type))) {
|
||||||
|
sql = new StringBuilder("SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?");
|
||||||
|
ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "sro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
} else {
|
||||||
|
sql.append(" UNION ALL SELECT sro.id, sro.order_no AS orderNo, sro.order_time AS orderTime, sro.amount, c.name AS customerName, 'sale.return' AS docType FROM sales_return_orders sro LEFT JOIN customers c ON c.id = sro.customer_id WHERE sro.shop_id=?");
|
||||||
|
ps.add(shopId);
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (sro.order_no LIKE ?)"); ps.add('%' + kw + '%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "sro.order_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND sro.order_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND sro.order_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY orderTime DESC LIMIT ? OFFSET ?");
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.add(size);
|
||||||
|
ps.add(page * size);
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
|
||||||
|
// 汇总:净额(主单 - 退货)
|
||||||
|
java.math.BigDecimal total;
|
||||||
|
if ("purchase".equals(biz)) {
|
||||||
|
java.math.BigDecimal inSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId));
|
||||||
|
java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM purchase_return_orders WHERE shop_id=?", java.math.BigDecimal.class, shopId));
|
||||||
|
total = inSum.subtract(retSum);
|
||||||
|
} else {
|
||||||
|
java.math.BigDecimal outSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId));
|
||||||
|
java.math.BigDecimal retSum = n(jdbcTemplate.queryForObject("SELECT COALESCE(SUM(amount),0) FROM sales_return_orders WHERE shop_id=? AND status='approved'", java.math.BigDecimal.class, shopId));
|
||||||
|
total = outSum.subtract(retSum);
|
||||||
|
}
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>();
|
||||||
|
resp.put("list", list);
|
||||||
|
resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Map<String,Object> listPayments(Long shopId, Long userId, String direction, String bizType, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT p.id, p.biz_type AS bizType, p.account_id, a.name AS accountName, p.direction, p.amount, p.pay_time AS orderTime, p.category AS category,\n" +
|
||||||
|
"CASE \n" +
|
||||||
|
" WHEN p.biz_type='sale' AND p.direction='in' THEN 'sale.collect' \n" +
|
||||||
|
" WHEN p.biz_type='purchase' AND p.direction='out' THEN 'purchase.pay' \n" +
|
||||||
|
" WHEN p.biz_type='other' AND p.direction='in' THEN 'other.income' \n" +
|
||||||
|
" WHEN p.biz_type='other' AND p.direction='out' THEN 'other.expense' \n" +
|
||||||
|
" ELSE CONCAT(p.biz_type, '.', p.direction) END AS docType\n" +
|
||||||
|
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.shop_id=?");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>();
|
||||||
|
ps.add(shopId);
|
||||||
|
if (direction != null && !direction.isBlank()) { sql.append(" AND p.direction=?"); ps.add(direction); }
|
||||||
|
if (bizType != null && !bizType.isBlank()) { sql.append(" AND p.biz_type=?"); ps.add(bizType); }
|
||||||
|
if (accountId != null) { sql.append(" AND p.account_id=?"); ps.add(accountId); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "p.pay_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND p.pay_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND p.pay_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
sql.append(" ORDER BY p.pay_time DESC LIMIT ? OFFSET ?");
|
||||||
|
ps.add(size); ps.add(page * size);
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(p.amount),0) FROM payments p WHERE p.shop_id=?");
|
||||||
|
java.util.List<Object> sumPs = new java.util.ArrayList<>(); sumPs.add(shopId);
|
||||||
|
if (direction != null && !direction.isBlank()) { sumSql.append(" AND p.direction=?"); sumPs.add(direction); }
|
||||||
|
if (bizType != null && !bizType.isBlank()) { sumSql.append(" AND p.biz_type=?"); sumPs.add(bizType); }
|
||||||
|
if (accountId != null) { sumSql.append(" AND p.account_id=?"); sumPs.add(accountId); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (CAST(p.id AS CHAR) LIKE ?)"); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "pay_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND p.pay_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND p.pay_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Map<String,Object> listOtherTransactions(Long shopId, Long userId, String type, Long accountId, String kw, int page, int size, String startDate, String endDate) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT ot.id, ot.`type`, CONCAT('other.', ot.`type`) AS docType, ot.account_id, a.name AS accountName, ot.amount, ot.tx_time AS txTime, ot.remark FROM other_transactions ot LEFT JOIN accounts a ON a.id=ot.account_id WHERE ot.shop_id=?");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (type != null && !type.isBlank()) { sql.append(" AND ot.`type`=?"); ps.add(type); }
|
||||||
|
if (accountId != null) { sql.append(" AND ot.account_id=?"); ps.add(accountId); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "ot.tx_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND ot.tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND ot.tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
sql.append(" ORDER BY ot.tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(ot.amount),0) FROM other_transactions ot WHERE ot.shop_id=?");
|
||||||
|
java.util.List<Object> sumPs = new java.util.ArrayList<>(); sumPs.add(shopId);
|
||||||
|
if (type != null && !type.isBlank()) { sumSql.append(" AND ot.`type`=?"); sumPs.add(type); }
|
||||||
|
if (accountId != null) { sumSql.append(" AND ot.account_id=?"); sumPs.add(accountId); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (ot.remark LIKE ? OR ot.category LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "tx_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND ot.tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND ot.tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
|
public java.util.Map<String, Object> createOtherTransaction(Long shopId, Long userId, OrderDtos.CreateOtherTransactionRequest req) {
|
||||||
|
if (req == null) throw new IllegalArgumentException("请求为空");
|
||||||
|
String type = req.type == null ? null : req.type.trim().toLowerCase();
|
||||||
|
if (!"income".equals(type) && !"expense".equals(type)) throw new IllegalArgumentException("type 仅支持 income/expense");
|
||||||
|
if (req.accountId == null) throw new IllegalArgumentException("账户必选");
|
||||||
|
java.math.BigDecimal amt = n(req.amount);
|
||||||
|
if (amt.compareTo(java.math.BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("金额需大于0");
|
||||||
|
java.time.LocalDateTime when;
|
||||||
|
if (req.txTime == null || req.txTime.isBlank()) when = nowUtc();
|
||||||
|
else {
|
||||||
|
// 允许 yyyy-MM-dd 或完整时间
|
||||||
|
try {
|
||||||
|
if (req.txTime.length() == 10) when = java.time.LocalDate.parse(req.txTime).atStartOfDay();
|
||||||
|
else when = java.time.LocalDateTime.parse(req.txTime);
|
||||||
|
} catch (Exception e) { when = nowUtc(); }
|
||||||
|
}
|
||||||
|
final java.sql.Timestamp whenTs = java.sql.Timestamp.from(when.atZone(java.time.ZoneOffset.UTC).toInstant());
|
||||||
|
|
||||||
|
// 插入 other_transactions
|
||||||
|
org.springframework.jdbc.support.GeneratedKeyHolder kh = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
String sql = "INSERT INTO other_transactions (shop_id,user_id,`type`,category,counterparty_type,counterparty_id,account_id,amount,tx_time,remark,created_at,updated_at) " +
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?, ?, NOW(), NOW())";
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
java.sql.PreparedStatement ps = con.prepareStatement(sql, new String[]{"id"});
|
||||||
|
int i = 1;
|
||||||
|
ps.setLong(i++, shopId);
|
||||||
|
ps.setLong(i++, userId);
|
||||||
|
ps.setString(i++, type);
|
||||||
|
ps.setString(i++, req.category);
|
||||||
|
if (req.counterpartyType == null) ps.setNull(i++, java.sql.Types.VARCHAR); else ps.setString(i++, req.counterpartyType);
|
||||||
|
if (req.counterpartyId == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, req.counterpartyId);
|
||||||
|
ps.setLong(i++, req.accountId);
|
||||||
|
ps.setBigDecimal(i++, scale2(amt));
|
||||||
|
ps.setTimestamp(i++, whenTs);
|
||||||
|
ps.setString(i, req.remark);
|
||||||
|
return ps;
|
||||||
|
}, kh);
|
||||||
|
Number key = kh.getKey();
|
||||||
|
Long id = key == null ? null : key.longValue();
|
||||||
|
|
||||||
|
// 写支付流水,联动账户余额
|
||||||
|
String direction = "income".equals(type) ? "in" : "out";
|
||||||
|
org.springframework.jdbc.support.GeneratedKeyHolder payKh = new org.springframework.jdbc.support.GeneratedKeyHolder();
|
||||||
|
final Long idForPayment = id;
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
java.sql.PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO payments (shop_id,user_id,biz_type,biz_id,account_id,direction,amount,pay_time,remark,category,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW())",
|
||||||
|
new String[]{"id"}
|
||||||
|
);
|
||||||
|
int i = 1;
|
||||||
|
ps.setLong(i++, shopId);
|
||||||
|
ps.setLong(i++, userId);
|
||||||
|
ps.setString(i++, "other");
|
||||||
|
if (idForPayment == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, idForPayment);
|
||||||
|
ps.setLong(i++, req.accountId);
|
||||||
|
ps.setString(i++, direction);
|
||||||
|
ps.setBigDecimal(i++, scale2(amt));
|
||||||
|
ps.setTimestamp(i++, whenTs);
|
||||||
|
ps.setString(i++, req.remark);
|
||||||
|
ps.setString(i, req.category);
|
||||||
|
return ps;
|
||||||
|
}, payKh);
|
||||||
|
|
||||||
|
// 联动账户余额:收入加,支出减
|
||||||
|
java.math.BigDecimal delta = "income".equals(type) ? amt : amt.negate();
|
||||||
|
jdbcTemplate.update("UPDATE accounts SET balance = balance + ?, updated_at=NOW() WHERE id=? AND shop_id=?", scale2(delta), req.accountId, shopId);
|
||||||
|
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>();
|
||||||
|
resp.put("id", id);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Map<String,Object> listInventoryLogs(Long shopId, Long userId, Long productId, String reason, String kw, int page, int size, String startDate, String endDate) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT id, product_id, qty_delta, amount_delta, COALESCE(amount_delta,0) AS amount, reason, tx_time AS txTime, remark FROM inventory_movements WHERE shop_id=?");
|
||||||
|
java.util.List<Object> ps = new java.util.ArrayList<>(); ps.add(shopId);
|
||||||
|
if (productId != null) { sql.append(" AND product_id=?"); ps.add(productId); }
|
||||||
|
if (reason != null && !reason.isBlank()) { sql.append(" AND reason=?"); ps.add(reason); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); ps.add('%'+kw+'%'); ps.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sql, ps, userId, "tx_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sql.append(" AND tx_time>=?"); ps.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sql.append(" AND tx_time<=?"); ps.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
sql.append(" ORDER BY tx_time DESC LIMIT ? OFFSET ?"); ps.add(size); ps.add(page * size);
|
||||||
|
java.util.List<java.util.Map<String,Object>> list = jdbcTemplate.queryForList(sql.toString(), ps.toArray());
|
||||||
|
StringBuilder sumSql = new StringBuilder("SELECT COALESCE(SUM(COALESCE(amount_delta,0)),0) FROM inventory_movements WHERE shop_id=?");
|
||||||
|
java.util.List<Object> sumPs = new java.util.ArrayList<>(); sumPs.add(shopId);
|
||||||
|
if (productId != null) { sumSql.append(" AND product_id=?"); sumPs.add(productId); }
|
||||||
|
if (reason != null && !reason.isBlank()) { sumSql.append(" AND reason=?"); sumPs.add(reason); }
|
||||||
|
if (kw != null && !kw.isBlank()) { sumSql.append(" AND (remark LIKE ? OR source_type LIKE ?)"); sumPs.add('%'+kw+'%'); sumPs.add('%'+kw+'%'); }
|
||||||
|
applyNonVipWindow(sumSql, sumPs, userId, "tx_time");
|
||||||
|
if (startDate != null && !startDate.isBlank()) { sumSql.append(" AND tx_time>=?"); sumPs.add(java.sql.Timestamp.valueOf(startDate + " 00:00:00")); }
|
||||||
|
if (endDate != null && !endDate.isBlank()) { sumSql.append(" AND tx_time<=?"); sumPs.add(java.sql.Timestamp.valueOf(endDate + " 23:59:59")); }
|
||||||
|
java.math.BigDecimal total = jdbcTemplate.queryForObject(sumSql.toString(), java.math.BigDecimal.class, sumPs.toArray());
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(); resp.put("list", list); resp.put("totalAmount", total == null ? java.math.BigDecimal.ZERO : total); return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非VIP时间窗拼接:默认60天;VIP用户不加限制
|
||||||
|
private void applyNonVipWindow(StringBuilder sql, java.util.List<Object> ps, Long userId, String column) {
|
||||||
|
if (userId == null) return; // 无法判定用户,避免误拦截
|
||||||
|
boolean vip = isVipActive(userId);
|
||||||
|
if (vip) return;
|
||||||
|
int days = readNonVipRetentionDaysOrDefault(60);
|
||||||
|
if (days <= 0) return;
|
||||||
|
sql.append(" AND ").append(column).append(">= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? DAY)");
|
||||||
|
ps.add(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isVipActive(Long userId) {
|
||||||
|
try {
|
||||||
|
java.util.List<java.util.Map<String,Object>> rows = jdbcTemplate.queryForList("SELECT is_vip,status,expire_at FROM vip_users WHERE user_id=? ORDER BY id DESC LIMIT 1", userId);
|
||||||
|
if (rows.isEmpty()) return false;
|
||||||
|
java.util.Map<String,Object> r = rows.get(0);
|
||||||
|
int isVip = ((Number) r.getOrDefault("is_vip", 0)).intValue();
|
||||||
|
int status = ((Number) r.getOrDefault("status", 0)).intValue();
|
||||||
|
java.sql.Timestamp exp = (java.sql.Timestamp) r.get("expire_at");
|
||||||
|
boolean notExpired = (exp == null) || !exp.toInstant().isBefore(java.time.Instant.now());
|
||||||
|
return isVip == 1 && status == 1 && notExpired;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readNonVipRetentionDaysOrDefault(int dft) {
|
||||||
|
try {
|
||||||
|
String v = jdbcTemplate.query("SELECT value FROM system_parameters WHERE `key`='vip.dataRetentionDaysForNonVip' ORDER BY id DESC LIMIT 1",
|
||||||
|
rs -> rs.next() ? rs.getString(1) : null);
|
||||||
|
if (v == null) return dft;
|
||||||
|
v = v.trim();
|
||||||
|
if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1);
|
||||||
|
return Integer.parseInt(v);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return dft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情:销售单
|
||||||
|
public java.util.Map<String,Object> getSalesOrderDetail(Long shopId, Long id) {
|
||||||
|
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
|
||||||
|
"SELECT so.id, so.order_no AS orderNo, so.order_time AS orderTime, so.status, so.amount, so.paid_amount AS paidAmount, so.customer_id AS customerId, c.name AS customerName, so.remark\n" +
|
||||||
|
"FROM sales_orders so LEFT JOIN customers c ON c.id=so.customer_id WHERE so.shop_id=? AND so.id=?",
|
||||||
|
shopId, id);
|
||||||
|
if (heads.isEmpty()) return java.util.Map.of();
|
||||||
|
java.util.Map<String,Object> head = heads.get(0);
|
||||||
|
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
|
||||||
|
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.discount_rate AS discountRate, i.cost_price AS costPrice, i.cost_amount AS costAmount, i.amount\n" +
|
||||||
|
"FROM sales_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
|
||||||
|
id);
|
||||||
|
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
|
||||||
|
"SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" +
|
||||||
|
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='sale' AND p.biz_id=?",
|
||||||
|
id);
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(head);
|
||||||
|
resp.put("items", items);
|
||||||
|
resp.put("payments", pays);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情:进货单
|
||||||
|
public java.util.Map<String,Object> getPurchaseOrderDetail(Long shopId, Long id) {
|
||||||
|
java.util.List<java.util.Map<String,Object>> heads = jdbcTemplate.queryForList(
|
||||||
|
"SELECT po.id, po.order_no AS orderNo, po.order_time AS orderTime, po.status, po.amount, po.paid_amount AS paidAmount, po.supplier_id AS supplierId, s.name AS supplierName, po.remark\n" +
|
||||||
|
"FROM purchase_orders po LEFT JOIN suppliers s ON s.id=po.supplier_id WHERE po.shop_id=? AND po.id=?",
|
||||||
|
shopId, id);
|
||||||
|
if (heads.isEmpty()) return java.util.Map.of();
|
||||||
|
java.util.Map<String,Object> head = heads.get(0);
|
||||||
|
java.util.List<java.util.Map<String,Object>> items = jdbcTemplate.queryForList(
|
||||||
|
"SELECT i.id, i.product_id AS productId, p.name, p.spec, i.quantity, i.unit_price AS unitPrice, i.amount\n" +
|
||||||
|
"FROM purchase_order_items i JOIN products p ON p.id=i.product_id WHERE i.order_id=?",
|
||||||
|
id);
|
||||||
|
java.util.List<java.util.Map<String,Object>> pays = jdbcTemplate.queryForList(
|
||||||
|
"SELECT p.id, p.amount, p.pay_time AS payTime, p.account_id AS accountId, a.name AS accountName, p.direction\n" +
|
||||||
|
"FROM payments p LEFT JOIN accounts a ON a.id=p.account_id WHERE p.biz_type='purchase' AND p.biz_id=?",
|
||||||
|
id);
|
||||||
|
java.util.Map<String,Object> resp = new java.util.HashMap<>(head);
|
||||||
|
resp.put("items", items);
|
||||||
|
resp.put("payments", pays);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal n(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; }
|
||||||
|
private static BigDecimal scale2(BigDecimal v) { return v.setScale(2, java.math.RoundingMode.HALF_UP); }
|
||||||
|
private static LocalDateTime nowUtc() { return LocalDateTime.now(java.time.Clock.systemUTC()); }
|
||||||
|
|
||||||
|
private void adjustSupplierPayable(Long supplierId, java.math.BigDecimal delta) {
|
||||||
|
if (supplierId == null || delta == null) return;
|
||||||
|
jdbcTemplate.update("UPDATE suppliers SET ap_payable = ap_payable + ? WHERE id = ?", delta, supplierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureDefaultAccounts(Long shopId, Long userId) {
|
||||||
|
// 为 cash/bank/wechat/alipay 分别确保存在一条账户记录;按 type→name 顺序检查,避免同名唯一冲突
|
||||||
|
ensureAccount(shopId, userId, "cash", accountDefaults.getCashName());
|
||||||
|
ensureAccount(shopId, userId, "bank", accountDefaults.getBankName());
|
||||||
|
ensureAccount(shopId, userId, "wechat", accountDefaults.getWechatName());
|
||||||
|
ensureAccount(shopId, userId, "alipay", accountDefaults.getAlipayName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureAccount(Long shopId, Long userId, String type, String name) {
|
||||||
|
List<Long> byType = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type);
|
||||||
|
if (!byType.isEmpty()) return;
|
||||||
|
List<Long> byName = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!byName.isEmpty()) return; // 已有同名则直接复用,无需再插
|
||||||
|
jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,balance,status,created_at,updated_at) VALUES (?,?,?,?,0,1,NOW(),NOW())",
|
||||||
|
shopId, userId, name, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveAccountId(Long shopId, Long userId, String method) {
|
||||||
|
String type = "cash";
|
||||||
|
if ("bank".equalsIgnoreCase(method)) type = "bank";
|
||||||
|
if ("wechat".equalsIgnoreCase(method)) type = "wechat";
|
||||||
|
if ("alipay".equalsIgnoreCase(method)) type = "alipay";
|
||||||
|
String name = accountDefaults.getCashName();
|
||||||
|
if ("bank".equals(type)) name = accountDefaults.getBankName();
|
||||||
|
else if ("wechat".equals(type)) name = accountDefaults.getWechatName();
|
||||||
|
else if ("alipay".equals(type)) name = accountDefaults.getAlipayName();
|
||||||
|
// 先按 type 查
|
||||||
|
List<Long> byType = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type);
|
||||||
|
if (!byType.isEmpty()) return byType.get(0);
|
||||||
|
// 再按 name 查,避免同名唯一冲突
|
||||||
|
List<Long> byName = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND name=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, name);
|
||||||
|
if (!byName.isEmpty()) return byName.get(0);
|
||||||
|
// 都没有再插入
|
||||||
|
jdbcTemplate.update("INSERT INTO accounts (shop_id,user_id,name,type,balance,status,created_at,updated_at) VALUES (?,?,?,?,0,1,NOW(),NOW())",
|
||||||
|
shopId, userId, name, type);
|
||||||
|
// 插入后按 type 读取
|
||||||
|
List<Long> recheck = jdbcTemplate.query("SELECT id FROM accounts WHERE shop_id=? AND type=? LIMIT 1", (rs,rn)->rs.getLong(1), shopId, type);
|
||||||
|
if (!recheck.isEmpty()) return recheck.get(0);
|
||||||
|
throw new IllegalStateException("账户映射失败: " + method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.demo.order.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class OrderDtos {
|
||||||
|
|
||||||
|
public static class CreateOrderRequest {
|
||||||
|
public String type; // sale.out / sale.return / sale.collect / purchase.in / purchase.return / purchase.pay
|
||||||
|
public String orderTime; // ISO8601 或 yyyy-MM-dd
|
||||||
|
public Long customerId; // 可空
|
||||||
|
public Long supplierId; // 可空
|
||||||
|
public List<Item> items; // 出入库时必填
|
||||||
|
public List<PaymentItem> payments; // 收款/付款时必填
|
||||||
|
public BigDecimal amount; // 前端提供,后端将重算覆盖
|
||||||
|
public String remark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Item {
|
||||||
|
public Long productId;
|
||||||
|
public BigDecimal quantity;
|
||||||
|
public BigDecimal unitPrice;
|
||||||
|
public BigDecimal discountRate; // 可空,缺省 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PaymentItem {
|
||||||
|
public String method; // cash/bank/wechat
|
||||||
|
public BigDecimal amount;
|
||||||
|
public Long orderId; // 可选:若挂单则带上
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateOrderResponse {
|
||||||
|
public Long id;
|
||||||
|
public String orderNo;
|
||||||
|
public CreateOrderResponse(Long id, String orderNo) { this.id = id; this.orderNo = orderNo; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreatePaymentsResponse {
|
||||||
|
public java.util.List<Long> paymentIds;
|
||||||
|
public CreatePaymentsResponse(java.util.List<Long> ids) { this.paymentIds = ids; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateOtherTransactionRequest {
|
||||||
|
public String type; // income | expense
|
||||||
|
public String category; // 分类key
|
||||||
|
public String counterpartyType; // customer | supplier | other
|
||||||
|
public Long counterpartyId; // 可空
|
||||||
|
public Long accountId; // 必填
|
||||||
|
public java.math.BigDecimal amount; // 必填,>0
|
||||||
|
public String txTime; // yyyy-MM-dd 或 ISO8601
|
||||||
|
public String remark; // 可空
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.product.repo.CategoryRepository;
|
||||||
|
import com.example.demo.product.repo.UnitRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import com.example.demo.product.repo.PartTemplateRepository;
|
||||||
|
import com.example.demo.product.repo.PartTemplateParamRepository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class MetadataController {
|
||||||
|
|
||||||
|
private final UnitRepository unitRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
private final PartTemplateRepository templateRepository;
|
||||||
|
private final PartTemplateParamRepository paramRepository;
|
||||||
|
|
||||||
|
public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults,
|
||||||
|
PartTemplateRepository templateRepository, PartTemplateParamRepository paramRepository) {
|
||||||
|
this.unitRepository = unitRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.defaults = defaults;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
this.paramRepository = paramRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/product-units")
|
||||||
|
public ResponseEntity<?> listUnits(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("list", unitRepository.listByShop(defaults.getDictShopId()));
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/product-categories")
|
||||||
|
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("list", categoryRepository.listByShop(defaults.getDictShopId()));
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/product-templates")
|
||||||
|
public ResponseEntity<?> listTemplates(@RequestParam(name = "categoryId", required = false) Long categoryId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
// 排除已软删模板;仍要求 status=1 才可见
|
||||||
|
java.util.List<com.example.demo.product.entity.PartTemplate> list =
|
||||||
|
(categoryId == null)
|
||||||
|
? templateRepository.findByStatusOrderByIdDesc(1)
|
||||||
|
: templateRepository.findByStatusAndCategoryIdOrderByIdDesc(1, categoryId);
|
||||||
|
java.util.List<java.util.Map<String,Object>> out = new java.util.ArrayList<>();
|
||||||
|
for (com.example.demo.product.entity.PartTemplate t : list) {
|
||||||
|
try { if (t.getDeletedAt() != null) continue; } catch (Exception ignore) {}
|
||||||
|
java.util.Map<String,Object> m = new java.util.HashMap<>();
|
||||||
|
m.put("id", t.getId());
|
||||||
|
m.put("categoryId", t.getCategoryId());
|
||||||
|
m.put("name", t.getName());
|
||||||
|
m.put("modelRule", t.getModelRule());
|
||||||
|
m.put("status", t.getStatus());
|
||||||
|
java.util.List<com.example.demo.product.entity.PartTemplateParam> params =
|
||||||
|
paramRepository.findByTemplateIdOrderBySortOrderAscIdAsc(t.getId());
|
||||||
|
java.util.List<java.util.Map<String,Object>> ps = new java.util.ArrayList<>();
|
||||||
|
for (com.example.demo.product.entity.PartTemplateParam p : params) {
|
||||||
|
java.util.Map<String,Object> pm = new java.util.HashMap<>();
|
||||||
|
pm.put("fieldKey", p.getFieldKey());
|
||||||
|
pm.put("fieldLabel", p.getFieldLabel());
|
||||||
|
pm.put("type", p.getType());
|
||||||
|
pm.put("required", p.getRequired());
|
||||||
|
pm.put("unit", p.getUnit());
|
||||||
|
java.util.List<String> enums = com.example.demo.common.JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<java.util.List<String>>(){});
|
||||||
|
pm.put("enumOptions", enums);
|
||||||
|
pm.put("searchable", p.getSearchable());
|
||||||
|
// 不再暴露 dedupeParticipate
|
||||||
|
pm.put("sortOrder", p.getSortOrder());
|
||||||
|
ps.add(pm);
|
||||||
|
}
|
||||||
|
m.put("params", ps);
|
||||||
|
out.add(m);
|
||||||
|
}
|
||||||
|
body.put("list", out);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.product.service.ProductSubmissionService;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/normal-admin/parts")
|
||||||
|
public class NormalAdminSubmissionController {
|
||||||
|
|
||||||
|
private final ProductSubmissionService submissionService;
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public NormalAdminSubmissionController(ProductSubmissionService submissionService,
|
||||||
|
JdbcTemplate jdbc) {
|
||||||
|
this.submissionService = submissionService;
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long findShopIdByUser(Long userId) {
|
||||||
|
if (userId == null) return null;
|
||||||
|
return jdbc.query("SELECT shop_id FROM users WHERE id=?", ps -> ps.setLong(1, userId), rs -> rs.next()? rs.getLong(1): null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理现有管理端接口,但范围限定为当前用户所属店铺
|
||||||
|
@GetMapping("/submissions")
|
||||||
|
public ResponseEntity<?> list(@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
Long shopId = findShopIdByUser(userId);
|
||||||
|
return ResponseEntity.ok(submissionService.listAdmin(status, kw, shopId, null, null, null, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return submissionService.findDetail(id)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/submissions/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody com.example.demo.product.dto.ProductSubmissionDtos.UpdateRequest req) {
|
||||||
|
submissionService.updateSubmission(id, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody(required = false) com.example.demo.product.dto.ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
var resp = submissionService.approve(id, userId, req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/submissions/{id}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody com.example.demo.product.dto.ProductSubmissionDtos.RejectRequest req) {
|
||||||
|
submissionService.reject(id, userId, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/submissions/export")
|
||||||
|
public void export(@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
Long shopId = findShopIdByUser(userId);
|
||||||
|
submissionService.export(status, kw, shopId, null, null, null, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.product.dto.PartTemplateDtos;
|
||||||
|
import com.example.demo.product.service.PartTemplateService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/part-templates")
|
||||||
|
public class PartTemplateController {
|
||||||
|
|
||||||
|
private final PartTemplateService templateService;
|
||||||
|
|
||||||
|
public PartTemplateController(PartTemplateService templateService) {
|
||||||
|
this.templateService = templateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody PartTemplateDtos.CreateRequest req,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId) {
|
||||||
|
Long id = templateService.create(req, adminId);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody PartTemplateDtos.UpdateRequest req) {
|
||||||
|
templateService.update(id, req);
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return ResponseEntity.ok(templateService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> list() {
|
||||||
|
return ResponseEntity.ok(templateService.list());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<?> delete(@PathVariable("id") Long id,
|
||||||
|
@RequestParam(value = "force", required = false) Boolean force) {
|
||||||
|
templateService.delete(id, Boolean.TRUE.equals(force));
|
||||||
|
return ResponseEntity.ok(java.util.Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类级联软删将在 AdminDictController 中触发;此处保持模板单体删除逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.product.dto.ProductDtos;
|
||||||
|
import com.example.demo.product.service.ProductService;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/products")
|
||||||
|
public class ProductController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ProductController(ProductService productService, AppDefaultsProperties defaults) {
|
||||||
|
this.productService = productService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<?> search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||||
|
@RequestParam(name = "templateId", required = false) Long templateId,
|
||||||
|
@RequestParam java.util.Map<String, String> requestParams,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "50") int size) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
java.util.Map<String, String> paramFilters = new java.util.HashMap<>();
|
||||||
|
for (java.util.Map.Entry<String, String> e : requestParams.entrySet()) {
|
||||||
|
String k = e.getKey();
|
||||||
|
if (k != null && k.startsWith("param_") && e.getValue() != null && !e.getValue().isBlank()) {
|
||||||
|
String key = k.substring(6);
|
||||||
|
if (!key.isBlank()) paramFilters.put(key, e.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Page<ProductDtos.ProductListItem> result = productService.search(sid, kw, categoryId, templateId, paramFilters, Math.max(page - 1, 0), size);
|
||||||
|
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("list", result.getContent());
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id,
|
||||||
|
@RequestParam(name = "includeDeleted", required = false, defaultValue = "false") boolean includeDeleted) {
|
||||||
|
return productService.findDetail(id, includeDeleted)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
Long id = productService.create(sid, uid, req);
|
||||||
|
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("id", id);
|
||||||
|
return ResponseEntity.ok(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
Long uid = (userId == null ? defaults.getUserId() : userId);
|
||||||
|
productService.update(id, sid, uid, req);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<?> delete(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Long sid = (shopId == null ? defaults.getShopId() : shopId);
|
||||||
|
productService.delete(id, sid);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.example.demo.product.controller;
|
||||||
|
|
||||||
|
import com.example.demo.common.AppDefaultsProperties;
|
||||||
|
import com.example.demo.product.dto.ProductSubmissionDtos;
|
||||||
|
import com.example.demo.product.service.ProductSubmissionService;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping
|
||||||
|
public class ProductSubmissionController {
|
||||||
|
|
||||||
|
private final ProductSubmissionService submissionService;
|
||||||
|
private final AppDefaultsProperties defaults;
|
||||||
|
|
||||||
|
public ProductSubmissionController(ProductSubmissionService submissionService,
|
||||||
|
AppDefaultsProperties defaults) {
|
||||||
|
this.submissionService = submissionService;
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/products/submissions")
|
||||||
|
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestBody ProductSubmissionDtos.CreateRequest req) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
Long id = submissionService.createSubmission(sid, uid, req);
|
||||||
|
return ResponseEntity.ok(Map.of("id", id, "status", "pending"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/products/submissions/check-model")
|
||||||
|
public ResponseEntity<?> checkModel(@RequestBody ProductSubmissionDtos.CheckModelRequest req) {
|
||||||
|
ProductSubmissionDtos.CheckModelResponse resp = submissionService.checkModel(req == null ? null : req.model,
|
||||||
|
req == null ? null : req.templateId,
|
||||||
|
req == null ? null : req.name);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/products/submissions")
|
||||||
|
public ResponseEntity<?> listMine(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId,
|
||||||
|
@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
return ResponseEntity.ok(submissionService.listMine(sid, uid, status, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/products/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detailMine(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
|
||||||
|
@RequestHeader(name = "X-User-Id", required = false) Long userId) {
|
||||||
|
Long sid = shopId == null ? defaults.getShopId() : shopId;
|
||||||
|
Long uid = userId == null ? defaults.getUserId() : userId;
|
||||||
|
return submissionService.findMineDetail(id, sid, uid)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions")
|
||||||
|
public ResponseEntity<?> listAdmin(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "reviewerId", required = false) Long reviewerId,
|
||||||
|
@RequestParam(name = "startAt", required = false) String startAt,
|
||||||
|
@RequestParam(name = "endAt", required = false) String endAt,
|
||||||
|
@RequestParam(name = "page", defaultValue = "1") int page,
|
||||||
|
@RequestParam(name = "size", defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(submissionService.listAdmin(status, kw, shopId, reviewerId, startAt, endAt, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions/export")
|
||||||
|
public void export(@RequestParam(name = "status", required = false) String status,
|
||||||
|
@RequestParam(name = "kw", required = false) String kw,
|
||||||
|
@RequestParam(name = "shopId", required = false) Long shopId,
|
||||||
|
@RequestParam(name = "reviewerId", required = false) Long reviewerId,
|
||||||
|
@RequestParam(name = "startAt", required = false) String startAt,
|
||||||
|
@RequestParam(name = "endAt", required = false) String endAt,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
submissionService.export(status, kw, shopId, reviewerId, startAt, endAt, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/admin/parts/submissions/{id}")
|
||||||
|
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
|
||||||
|
return submissionService.findDetail(id)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/admin/parts/submissions/{id}")
|
||||||
|
public ResponseEntity<?> update(@PathVariable("id") Long id,
|
||||||
|
@RequestBody ProductSubmissionDtos.UpdateRequest req) {
|
||||||
|
submissionService.updateSubmission(id, req);
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/admin/parts/submissions/{id}/approve")
|
||||||
|
public ResponseEntity<?> approve(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody(required = false) ProductSubmissionDtos.ApproveRequest req) {
|
||||||
|
ProductSubmissionDtos.ApproveResponse resp = submissionService.approve(id, adminId, req);
|
||||||
|
return ResponseEntity.ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/admin/parts/submissions/{id}/reject")
|
||||||
|
public ResponseEntity<?> reject(@PathVariable("id") Long id,
|
||||||
|
@RequestHeader(name = "X-Admin-Id", required = false) Long adminId,
|
||||||
|
@RequestBody ProductSubmissionDtos.RejectRequest req) {
|
||||||
|
submissionService.reject(id, adminId, req);
|
||||||
|
return ResponseEntity.ok(Map.of("ok", true));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.example.demo.product.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class PartTemplateDtos {
|
||||||
|
|
||||||
|
public static class ParamDef {
|
||||||
|
public String fieldKey;
|
||||||
|
public String fieldLabel;
|
||||||
|
public String type; // string/number/boolean/enum/date
|
||||||
|
public boolean required;
|
||||||
|
public String unit; // 自定义单位文本
|
||||||
|
public List<String> enumOptions; // type=enum 时可用
|
||||||
|
public boolean searchable; // 默认参与搜索;前端不再展示开关
|
||||||
|
public boolean fuzzySearchable; // 仅 type=number 生效
|
||||||
|
public java.math.BigDecimal fuzzyTolerance; // 可空=使用默认
|
||||||
|
public boolean cardDisplay; // 是否在用户端货品卡片展示
|
||||||
|
// public boolean dedupeParticipate; // 已废弃,后端忽略
|
||||||
|
public int sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateRequest {
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule; // 可空
|
||||||
|
public Integer status; // 1/0
|
||||||
|
public List<ParamDef> params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateRequest {
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule;
|
||||||
|
public Integer status;
|
||||||
|
public List<ParamDef> params; // 覆盖式更新
|
||||||
|
public boolean deleteAllRelatedProductsAndSubmissions; // 开关:按你的规则默认true
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TemplateItem {
|
||||||
|
public Long id;
|
||||||
|
public Long categoryId;
|
||||||
|
public String name;
|
||||||
|
public String modelRule;
|
||||||
|
public Integer status;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TemplateDetail extends TemplateItem {
|
||||||
|
public List<ParamDef> params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.example.demo.product.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class ProductDtos {
|
||||||
|
|
||||||
|
public static class ProductListItem {
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String brand;
|
||||||
|
public String model;
|
||||||
|
public String spec;
|
||||||
|
public BigDecimal stock; // from inventories.quantity
|
||||||
|
public BigDecimal retailPrice; // from product_prices
|
||||||
|
public String cover; // first image url
|
||||||
|
public Boolean deleted; // derived from deleted_at
|
||||||
|
public java.util.Map<String, String> cardParams; // 货品卡片展示的参数(最多4个,label->value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ProductDetail {
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String barcode;
|
||||||
|
public String brand;
|
||||||
|
public String model;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
public Long categoryId;
|
||||||
|
// 单位字段已移除
|
||||||
|
public Long templateId;
|
||||||
|
public BigDecimal safeMin;
|
||||||
|
public BigDecimal safeMax;
|
||||||
|
public BigDecimal stock;
|
||||||
|
public BigDecimal purchasePrice;
|
||||||
|
public BigDecimal retailPrice;
|
||||||
|
public BigDecimal wholesalePrice;
|
||||||
|
public BigDecimal bigClientPrice;
|
||||||
|
public List<Image> images;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public Long sourceSubmissionId;
|
||||||
|
public String externalCode;
|
||||||
|
public Boolean deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Image {
|
||||||
|
public String url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CreateOrUpdateProductRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String barcode;
|
||||||
|
public String brand;
|
||||||
|
public String model;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
public Long categoryId;
|
||||||
|
public String dedupeKey;
|
||||||
|
public BigDecimal safeMin;
|
||||||
|
public BigDecimal safeMax;
|
||||||
|
public Prices prices;
|
||||||
|
public BigDecimal stock;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark; // map to products.description
|
||||||
|
public Long sourceSubmissionId;
|
||||||
|
public Long globalSkuId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Prices {
|
||||||
|
public BigDecimal purchasePrice;
|
||||||
|
public BigDecimal retailPrice;
|
||||||
|
public BigDecimal wholesalePrice;
|
||||||
|
public BigDecimal bigClientPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.example.demo.product.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class ProductSubmissionDtos {
|
||||||
|
|
||||||
|
public static class CreateRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String externalCode; // 外部编号
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
// 单位字段已移除
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String externalCode; // 外部编号
|
||||||
|
public String name;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
// 单位字段已移除
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApproveRequest {
|
||||||
|
public String remark;
|
||||||
|
public Long assignGlobalSkuId;
|
||||||
|
public boolean createGlobalSku;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApproveResponse {
|
||||||
|
public boolean ok;
|
||||||
|
public Long productId;
|
||||||
|
public Long globalSkuId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RejectRequest {
|
||||||
|
public String remark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SubmissionItem {
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String status;
|
||||||
|
public String submitter;
|
||||||
|
public Long shopId;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public LocalDateTime reviewedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SubmissionDetail {
|
||||||
|
public Long id;
|
||||||
|
public Long shopId;
|
||||||
|
public Long userId;
|
||||||
|
public Long templateId;
|
||||||
|
public String externalCode;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
public String brand;
|
||||||
|
public String spec;
|
||||||
|
public String origin;
|
||||||
|
// 单位字段已移除
|
||||||
|
public Long categoryId;
|
||||||
|
public Map<String, Object> parameters;
|
||||||
|
public List<String> images;
|
||||||
|
public String remark;
|
||||||
|
public String barcode;
|
||||||
|
public java.math.BigDecimal safeMin;
|
||||||
|
public java.math.BigDecimal safeMax;
|
||||||
|
public String status;
|
||||||
|
public Long reviewerId;
|
||||||
|
public String reviewRemark;
|
||||||
|
public LocalDateTime reviewedAt;
|
||||||
|
public LocalDateTime createdAt;
|
||||||
|
public String dedupeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PageResult<T> {
|
||||||
|
public List<T> list;
|
||||||
|
public long total;
|
||||||
|
public int page;
|
||||||
|
public int size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CheckModelRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public String name;
|
||||||
|
public String model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class CheckModelResponse {
|
||||||
|
public boolean available;
|
||||||
|
public String model;
|
||||||
|
public int similarAcrossTemplates; // 跨模板同名同型号命中数量(提示用)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "inventories")
|
||||||
|
public class Inventory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "product_id")
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "quantity", precision = 18, scale = 3, nullable = false)
|
||||||
|
private BigDecimal quantity = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public void setProductId(Long productId) { this.productId = productId; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public BigDecimal getQuantity() { return quantity; }
|
||||||
|
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "part_templates")
|
||||||
|
public class PartTemplate {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "category_id", nullable = false)
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "model_rule")
|
||||||
|
private String modelRule;
|
||||||
|
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
@Column(name = "created_by_admin_id")
|
||||||
|
private Long createdByAdminId;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getCategoryId() { return categoryId; }
|
||||||
|
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public String getModelRule() { return modelRule; }
|
||||||
|
public void setModelRule(String modelRule) { this.modelRule = modelRule; }
|
||||||
|
public Integer getStatus() { return status; }
|
||||||
|
public void setStatus(Integer status) { this.status = status; }
|
||||||
|
public Long getCreatedByAdminId() { return createdByAdminId; }
|
||||||
|
public void setCreatedByAdminId(Long createdByAdminId) { this.createdByAdminId = createdByAdminId; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "part_template_params")
|
||||||
|
public class PartTemplateParam {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "template_id", nullable = false)
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
@Column(name = "field_key", nullable = false, length = 64)
|
||||||
|
private String fieldKey;
|
||||||
|
|
||||||
|
@Column(name = "field_label", nullable = false, length = 120)
|
||||||
|
private String fieldLabel;
|
||||||
|
|
||||||
|
@Column(name = "type", nullable = false, length = 16)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
@Column(name = "required", nullable = false)
|
||||||
|
private Boolean required;
|
||||||
|
|
||||||
|
@Column(name = "unit", length = 32)
|
||||||
|
private String unit;
|
||||||
|
|
||||||
|
@Column(name = "enum_options", columnDefinition = "json")
|
||||||
|
private String enumOptionsJson;
|
||||||
|
|
||||||
|
@Column(name = "searchable", nullable = false)
|
||||||
|
private Boolean searchable;
|
||||||
|
|
||||||
|
@Column(name = "dedupe_participate", nullable = false)
|
||||||
|
private Boolean dedupeParticipate = false;
|
||||||
|
|
||||||
|
@Column(name = "fuzzy_searchable", nullable = false)
|
||||||
|
private Boolean fuzzySearchable = false;
|
||||||
|
|
||||||
|
@Column(name = "fuzzy_tolerance", precision = 18, scale = 6)
|
||||||
|
private BigDecimal fuzzyTolerance;
|
||||||
|
|
||||||
|
@Column(name = "card_display", nullable = false)
|
||||||
|
private Boolean cardDisplay = false;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getTemplateId() { return templateId; }
|
||||||
|
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||||
|
public String getFieldKey() { return fieldKey; }
|
||||||
|
public void setFieldKey(String fieldKey) { this.fieldKey = fieldKey; }
|
||||||
|
public String getFieldLabel() { return fieldLabel; }
|
||||||
|
public void setFieldLabel(String fieldLabel) { this.fieldLabel = fieldLabel; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public void setType(String type) { this.type = type; }
|
||||||
|
public Boolean getRequired() { return required; }
|
||||||
|
public void setRequired(Boolean required) { this.required = required; }
|
||||||
|
public String getUnit() { return unit; }
|
||||||
|
public void setUnit(String unit) { this.unit = unit; }
|
||||||
|
public String getEnumOptionsJson() { return enumOptionsJson; }
|
||||||
|
public void setEnumOptionsJson(String enumOptionsJson) { this.enumOptionsJson = enumOptionsJson; }
|
||||||
|
public Boolean getSearchable() { return searchable; }
|
||||||
|
public void setSearchable(Boolean searchable) { this.searchable = searchable; }
|
||||||
|
public Boolean getDedupeParticipate() { return dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate; }
|
||||||
|
public void setDedupeParticipate(Boolean dedupeParticipate) { this.dedupeParticipate = (dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate); }
|
||||||
|
public Boolean getFuzzySearchable() { return fuzzySearchable; }
|
||||||
|
public void setFuzzySearchable(Boolean fuzzySearchable) { this.fuzzySearchable = fuzzySearchable; }
|
||||||
|
public BigDecimal getFuzzyTolerance() { return fuzzyTolerance; }
|
||||||
|
public void setFuzzyTolerance(BigDecimal fuzzyTolerance) { this.fuzzyTolerance = fuzzyTolerance; }
|
||||||
|
public Boolean getCardDisplay() { return cardDisplay == null ? Boolean.FALSE : cardDisplay; }
|
||||||
|
public void setCardDisplay(Boolean cardDisplay) { this.cardDisplay = (cardDisplay == null ? Boolean.FALSE : cardDisplay); }
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "products")
|
||||||
|
public class Product {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 120)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "category_id")
|
||||||
|
private Long categoryId;
|
||||||
|
|
||||||
|
@Column(name = "template_id")
|
||||||
|
private Long templateId;
|
||||||
|
|
||||||
|
@Column(name = "brand", length = 64)
|
||||||
|
private String brand;
|
||||||
|
|
||||||
|
@Column(name = "model", length = 64)
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@Column(name = "spec", length = 128)
|
||||||
|
private String spec;
|
||||||
|
|
||||||
|
@Column(name = "origin", length = 64)
|
||||||
|
private String origin;
|
||||||
|
|
||||||
|
@Column(name = "barcode", length = 32)
|
||||||
|
private String barcode;
|
||||||
|
|
||||||
|
@Column(name = "dedupe_key", length = 512)
|
||||||
|
private String dedupeKey;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "safe_min", precision = 18, scale = 3)
|
||||||
|
private BigDecimal safeMin;
|
||||||
|
|
||||||
|
@Column(name = "safe_max", precision = 18, scale = 3)
|
||||||
|
private BigDecimal safeMax;
|
||||||
|
|
||||||
|
@Column(name = "global_sku_id")
|
||||||
|
private Long globalSkuId;
|
||||||
|
|
||||||
|
@Column(name = "source_submission_id")
|
||||||
|
private Long sourceSubmissionId;
|
||||||
|
|
||||||
|
@Column(name = "attributes_json", columnDefinition = "json")
|
||||||
|
private String attributesJson;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public Long getCategoryId() { return categoryId; }
|
||||||
|
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
|
||||||
|
public Long getTemplateId() { return templateId; }
|
||||||
|
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||||
|
public String getBrand() { return brand; }
|
||||||
|
public void setBrand(String brand) { this.brand = brand; }
|
||||||
|
public String getModel() { return model; }
|
||||||
|
public void setModel(String model) { this.model = model; }
|
||||||
|
public String getSpec() { return spec; }
|
||||||
|
public void setSpec(String spec) { this.spec = spec; }
|
||||||
|
public String getOrigin() { return origin; }
|
||||||
|
public void setOrigin(String origin) { this.origin = origin; }
|
||||||
|
public String getBarcode() { return barcode; }
|
||||||
|
public void setBarcode(String barcode) { this.barcode = barcode; }
|
||||||
|
public String getDedupeKey() { return dedupeKey; }
|
||||||
|
public void setDedupeKey(String dedupeKey) { this.dedupeKey = dedupeKey; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public BigDecimal getSafeMin() { return safeMin; }
|
||||||
|
public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; }
|
||||||
|
public BigDecimal getSafeMax() { return safeMax; }
|
||||||
|
public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; }
|
||||||
|
public Long getGlobalSkuId() { return globalSkuId; }
|
||||||
|
public void setGlobalSkuId(Long globalSkuId) { this.globalSkuId = globalSkuId; }
|
||||||
|
public Long getSourceSubmissionId() { return sourceSubmissionId; }
|
||||||
|
public void setSourceSubmissionId(Long sourceSubmissionId) { this.sourceSubmissionId = sourceSubmissionId; }
|
||||||
|
public String getAttributesJson() { return attributesJson; }
|
||||||
|
public void setAttributesJson(String attributesJson) { this.attributesJson = attributesJson; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "product_categories")
|
||||||
|
public class ProductCategory {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 64)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "parent_id")
|
||||||
|
private Long parentId;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public Long getParentId() { return parentId; }
|
||||||
|
public void setParentId(Long parentId) { this.parentId = parentId; }
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
public LocalDateTime getDeletedAt() { return deletedAt; }
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "product_images")
|
||||||
|
public class ProductImage {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "product_id", nullable = false)
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "url", nullable = false, length = 512)
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
private Integer sortOrder = 0;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public void setProductId(Long productId) { this.productId = productId; }
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public void setUrl(String url) { this.url = url; }
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.example.demo.product.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "product_prices")
|
||||||
|
public class ProductPrice {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "product_id")
|
||||||
|
private Long productId;
|
||||||
|
|
||||||
|
@Column(name = "shop_id", nullable = false)
|
||||||
|
private Long shopId;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Column(name = "purchase_price", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal purchasePrice = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "retail_price", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal retailPrice = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "wholesale_price", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal wholesalePrice = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "big_client_price", precision = 18, scale = 2, nullable = false)
|
||||||
|
private BigDecimal bigClientPrice = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public void setProductId(Long productId) { this.productId = productId; }
|
||||||
|
public Long getShopId() { return shopId; }
|
||||||
|
public void setShopId(Long shopId) { this.shopId = shopId; }
|
||||||
|
public Long getUserId() { return userId; }
|
||||||
|
public void setUserId(Long userId) { this.userId = userId; }
|
||||||
|
public BigDecimal getPurchasePrice() { return purchasePrice; }
|
||||||
|
public void setPurchasePrice(BigDecimal purchasePrice) { this.purchasePrice = purchasePrice; }
|
||||||
|
public BigDecimal getRetailPrice() { return retailPrice; }
|
||||||
|
public void setRetailPrice(BigDecimal retailPrice) { this.retailPrice = retailPrice; }
|
||||||
|
public BigDecimal getWholesalePrice() { return wholesalePrice; }
|
||||||
|
public void setWholesalePrice(BigDecimal wholesalePrice) { this.wholesalePrice = wholesalePrice; }
|
||||||
|
public BigDecimal getBigClientPrice() { return bigClientPrice; }
|
||||||
|
public void setBigClientPrice(BigDecimal bigClientPrice) { this.bigClientPrice = bigClientPrice; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user