准备上传

This commit is contained in:
2025-10-08 19:15:20 +08:00
parent 0aa7d55a23
commit 0e14a5fa1c
193 changed files with 14697 additions and 2461 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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
View File

@@ -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

View 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

View 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 '业务表IDsales_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
View 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 "$@"

View 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"

View 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>

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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 停用
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
// 管理员为平台操作不属于店铺用户使用默认用户ID1满足非空外键
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;
}
}

View File

@@ -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)); }
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; } }
}

View File

@@ -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));
}
}

View File

@@ -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("\"", "\\\"");
}
}

View File

@@ -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;
}
}

View File

@@ -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) { }
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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) {}
}

View File

@@ -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 "";
}
}

View File

@@ -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 "邮箱用户";
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 "手机用户";
}
}

View File

@@ -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 "";
}
}

View File

@@ -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 "手机用户";
}
}

View File

@@ -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\":\"识别服务不可用,请稍后重试\"}");
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
/** 健康检查路径GETFastAPI 默认可用 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;
}
}

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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方案Ashop_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; }
}

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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/"
);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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());
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,12 @@
package com.example.demo.notice;
/**
* 公告状态。
*/
public enum NoticeStatus {
DRAFT,
PUBLISHED,
OFFLINE
}

View File

@@ -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;
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; // 可空
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 中触发;此处保持模板单体删除逻辑
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; // 跨模板同名同型号命中数量(提示用)
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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