From 0c7ed3498dce9b977d261343ea6af192ad5f8c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E6=B5=B7=E6=B4=8B?= Date: Tue, 26 May 2026 13:37:55 +0800 Subject: [PATCH] Ai config --- .env.example | 7 ++ .eslintrc.json | 16 +++ app.js | 16 ++- config/constants.js | 42 +++++++ config/database.js | 16 +++ config/domain.js | 14 +-- config/schema.sql | 74 +++++++++++- controllers/addresses.js | 109 ++++++++++++++++++ controllers/categories.js | 14 +-- controllers/export.js | 87 ++++++++++++++ controllers/goods-specs.js | 83 ++++++++++++++ controllers/goods.js | 44 +++++++- controllers/orders.js | 114 +++++++++++++------ controllers/points-goods.js | 27 +++-- controllers/purchases.js | 74 ++++++------ controllers/recognize.js | 78 +++++++++++++ controllers/reports.js | 131 ++++++++++++++++++++++ controllers/stock.js | 5 +- controllers/subscribe.js | 37 ++++++ controllers/suppliers.js | 5 +- controllers/users.js | 168 ++++++++++++---------------- data/categories.json | 1 - data/goods.json | 1 - data/users.json | 1 - nginx | 65 ----------- package.json | 11 +- routes/addresses.js | 13 +++ routes/export.js | 11 ++ routes/goods-specs.js | 12 ++ routes/recognize.js | 85 +------------- routes/reports.js | 11 ++ routes/subscribe.js | 8 ++ routes/upload.js | 39 ++++--- scripts/convert-to-relative-urls.js | 101 ----------------- scripts/fix-image-urls.js | 70 ------------ scripts/init-db-root.js | 89 --------------- scripts/register-donghaiyang.js | 47 -------- scripts/register-staff.js | 50 --------- scripts/test-db.js | 36 ------ services/orderService.js | 112 +++++++++++++++++++ utils/pagination.js | 20 ++++ utils/wechat.js | 87 ++++++++++++++ 42 files changed, 1264 insertions(+), 767 deletions(-) create mode 100644 .eslintrc.json create mode 100644 config/constants.js create mode 100644 controllers/addresses.js create mode 100644 controllers/export.js create mode 100644 controllers/goods-specs.js create mode 100644 controllers/recognize.js create mode 100644 controllers/reports.js create mode 100644 controllers/subscribe.js delete mode 100644 data/categories.json delete mode 100644 data/goods.json delete mode 100644 data/users.json delete mode 100644 nginx create mode 100644 routes/addresses.js create mode 100644 routes/export.js create mode 100644 routes/goods-specs.js create mode 100644 routes/reports.js create mode 100644 routes/subscribe.js delete mode 100644 scripts/convert-to-relative-urls.js delete mode 100644 scripts/fix-image-urls.js delete mode 100644 scripts/init-db-root.js delete mode 100644 scripts/register-donghaiyang.js delete mode 100644 scripts/register-staff.js delete mode 100644 scripts/test-db.js create mode 100644 services/orderService.js create mode 100644 utils/pagination.js create mode 100644 utils/wechat.js diff --git a/.env.example b/.env.example index 2c40c44..57a181d 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ DB_NAME=miniprogram # AI 配置(阿里云 DashScope) DASHSCOPE_API_KEY=sk-your-api-key +# 微信小程序配置(订阅消息用) +WECHAT_APPID=your_appid +WECHAT_SECRET=your_secret +WECHAT_ORDER_TEMPLATE_ID=your_template_id + # 服务器配置 PORT=3006 NODE_ENV=development +CORS_ORIGIN=* +BASE_URL=http://your_ip_or_domain:3006 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..b6fe83a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "env": { + "node": true, + "commonjs": true, + "es2022": true + }, + "extends": ["eslint:recommended"], + "parserOptions": { + "ecmaVersion": 2022 + }, + "rules": { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-console": "off", + "no-undef": "error" + } +} diff --git a/app.js b/app.js index 8f0fd01..427ec1b 100644 --- a/app.js +++ b/app.js @@ -9,14 +9,14 @@ const app = new Koa() const router = new Router() app.use(cors({ - origin: '*', + origin: process.env.CORS_ORIGIN || '*', allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'] })) app.use(bodyParser({ - jsonLimit: '10mb', - formLimit: '10mb' + jsonLimit: '5mb', + formLimit: '5mb' })) app.use(require('koa-static')(path.join(__dirname, 'public'))) @@ -56,6 +56,11 @@ const statsRoutes = require('./routes/stats') const priceListRoutes = require('./routes/price-list') const pointsLogsRoutes = require('./routes/points-logs') const recognizeRoutes = require('./routes/recognize') +const reportRoutes = require('./routes/reports') +const exportRoutes = require('./routes/export') +const subscribeRoutes = require('./routes/subscribe') +const addressRoutes = require('./routes/addresses') +const goodsSpecRoutes = require('./routes/goods-specs') router.use('/api/orders', orderRoutes) router.use('/api/categories', categoryRoutes) @@ -71,6 +76,11 @@ router.use('/api/stats', statsRoutes) router.use('/api/price-list', priceListRoutes) router.use('/api/points/logs', pointsLogsRoutes) router.use('/api/recognize', recognizeRoutes) +router.use('/api/reports', reportRoutes) +router.use('/api/export', exportRoutes) +router.use('/api/subscribe', subscribeRoutes) +router.use('/api/addresses', addressRoutes) +router.use('/api/goods-specs', goodsSpecRoutes) app.use(router.routes()) app.use(router.allowedMethods()) diff --git a/config/constants.js b/config/constants.js new file mode 100644 index 0000000..77c4266 --- /dev/null +++ b/config/constants.js @@ -0,0 +1,42 @@ +module.exports = { + // 积分倍率 (每消费1元获得的积分) + POINTS_RATE: 1, + + // 默认密码 + DEFAULT_PASSWORD: process.env.DEFAULT_PASSWORD || '123456', + + // 利润率估算 + COST_RATIO: 0.6, + PROFIT_RATIO: 0.4, + + // 分页默认值 + DEFAULT_PAGE_SIZE: 20, + MAX_PAGE_SIZE: 100, + + // 库存预警阈值 + LOW_STOCK_THRESHOLD: 5, + + // 上传限制 + UPLOAD_MAX_SIZE: 5 * 1024 * 1024, + UPLOAD_ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + + // AI 配置 + AI_TEXT_MODEL: 'qwen3.5-flash', + AI_VL_MODEL: 'qwen-vl-max', + AI_TEMPERATURE: 0.7, + AI_VL_TEMPERATURE: 0.3, + AI_MAX_TOKENS: 500, + AI_TIMEOUT: 30000, + AI_VL_TIMEOUT: 60000, + AI_RECOGNIZE_LIMIT: 5, + + // 微信通知 + WECHAT_TOKEN_BUFFER: 300, + WECHAT_MINIPROGRAM_STATE: 'formal', + + // 默认分类颜色 + DEFAULT_CATEGORY_COLOR: '#1890ff', + + // 订单号随机后缀范围 + ORDER_ID_RANDOM_MAX: 1000, +} diff --git a/config/database.js b/config/database.js index d8f205c..13bb7d5 100644 --- a/config/database.js +++ b/config/database.js @@ -62,9 +62,25 @@ async function initDatabase() { } } +async function transaction(callback) { + const connection = await pool.getConnection() + try { + await connection.beginTransaction() + const result = await callback(connection) + await connection.commit() + return result + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() + } +} + module.exports = { pool, query, + transaction, initDatabase, config } \ No newline at end of file diff --git a/config/domain.js b/config/domain.js index 14e14cb..e989018 100644 --- a/config/domain.js +++ b/config/domain.js @@ -1,15 +1,9 @@ -// 服务端域名配置常量 - 备案完成后只需修改这里 +// 服务端域名配置 - 从环境变量读取,默认 IP +const BASE_URL = process.env.BASE_URL || 'http://110.42.255.239:3006' + const DOMAIN_CONFIG = { - // 临时域名(备案期间使用) - BASE_URL: 'http://110.42.255.239:3006', - - // 正式域名(备案完成后启用,取消注释并注释上面一行) - // BASE_URL: 'https://donghy.top', - - // API 路径前缀 + BASE_URL: BASE_URL, API_PATH: '/api', - - // 图片路径前缀 IMG_PATH: '/img' } diff --git a/config/schema.sql b/config/schema.sql index 1c77bac..3da3edb 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS `categories` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '分类名称', `icon` varchar(50) DEFAULT NULL COMMENT '分类图标', + `color` varchar(20) DEFAULT '#1890ff' COMMENT '分类颜色', `is_show` tinyint(1) DEFAULT 1 COMMENT '是否显示', `sort_order` int(11) DEFAULT 0 COMMENT '排序顺序', `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, @@ -9,6 +10,8 @@ CREATE TABLE IF NOT EXISTS `categories` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品分类表'; +ALTER TABLE `categories` ADD COLUMN IF NOT EXISTS `color` varchar(20) DEFAULT '#1890ff' COMMENT '分类颜色'; + CREATE TABLE IF NOT EXISTS `goods` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(200) NOT NULL COMMENT '商品名称', @@ -39,10 +42,13 @@ CREATE TABLE IF NOT EXISTS `users` ( `points` int(11) DEFAULT 0 COMMENT '积分', `role` tinyint(1) DEFAULT 0 COMMENT '角色 0-普通用户 1-店员', `status` tinyint(1) DEFAULT 1 COMMENT '状态 1-正常 0-禁用', + `openid` varchar(100) DEFAULT NULL COMMENT '微信openid', + `token` varchar(100) DEFAULT NULL COMMENT '登录token', `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `phone` (`phone`) + UNIQUE KEY `phone` (`phone`), + KEY `openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; CREATE TABLE IF NOT EXISTS `orders` ( @@ -59,6 +65,23 @@ CREATE TABLE IF NOT EXISTS `orders` ( CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表'; +CREATE TABLE IF NOT EXISTS `order_items` ( + `id` int NOT NULL AUTO_INCREMENT, + `order_id` varchar(64) NOT NULL COMMENT '订单ID', + `goods_id` int NOT NULL COMMENT '商品ID', + `goods_name` varchar(255) NOT NULL COMMENT '商品名称', + `price` decimal(10,2) NOT NULL COMMENT '单价', + `quantity` int NOT NULL DEFAULT 1 COMMENT '数量', + `weight` decimal(10,2) DEFAULT NULL COMMENT '称重(kg)', + `subtotal` decimal(10,2) NOT NULL COMMENT '小计', + `unit` varchar(20) DEFAULT '' COMMENT '单位', + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `order_id` (`order_id`), + KEY `goods_id` (`goods_id`), + CONSTRAINT `order_items_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单商品明细表'; + CREATE TABLE IF NOT EXISTS `points_logs` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户ID', @@ -123,6 +146,34 @@ CREATE TABLE IF NOT EXISTS `purchase_items` ( CONSTRAINT `purchase_items_ibfk_2` FOREIGN KEY (`goods_id`) REFERENCES `goods` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单明细表'; +CREATE TABLE IF NOT EXISTS `goods_specs` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `goods_id` int(11) NOT NULL COMMENT '商品ID', + `spec_name` varchar(100) NOT NULL COMMENT '规格名称(如 330ml/500ml/1L)', + `price` decimal(10,2) NOT NULL COMMENT '规格售价', + `stock` int(11) DEFAULT 0 COMMENT '规格库存', + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `goods_id` (`goods_id`), + CONSTRAINT `goods_specs_ibfk_1` FOREIGN KEY (`goods_id`) REFERENCES `goods` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品规格表'; + +CREATE TABLE IF NOT EXISTS `addresses` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL COMMENT '用户ID', + `name` varchar(100) NOT NULL COMMENT '收货人姓名', + `phone` varchar(20) NOT NULL COMMENT '联系电话', + `region` varchar(200) DEFAULT '' COMMENT '地区', + `detail` varchar(500) NOT NULL COMMENT '详细地址', + `is_default` tinyint(1) DEFAULT 0 COMMENT '是否默认', + `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `user_id` (`user_id`), + CONSTRAINT `addresses_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货地址表'; + CREATE TABLE IF NOT EXISTS `points_goods` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(200) NOT NULL COMMENT '商品名称', @@ -134,4 +185,23 @@ CREATE TABLE IF NOT EXISTS `points_goods` ( `created_at` timestamp DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分商品表'; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分商品表'; + +-- 订单表索引:按状态筛选、按时间排序 +ALTER TABLE `orders` ADD INDEX IF NOT EXISTS `idx_orders_status` (`status`); +ALTER TABLE `orders` ADD INDEX IF NOT EXISTS `idx_orders_created_at` (`created_at`); + +-- 商品表索引:热销/新品筛选、名称搜索 +ALTER TABLE `goods` ADD INDEX IF NOT EXISTS `idx_goods_is_hot` (`is_hot`); +ALTER TABLE `goods` ADD INDEX IF NOT EXISTS `idx_goods_is_new` (`is_new`); +ALTER TABLE `goods` ADD INDEX IF NOT EXISTS `idx_goods_name` (`name`(100)); + +-- 分类表索引:按排序字段排序 +ALTER TABLE `categories` ADD INDEX IF NOT EXISTS `idx_categories_sort_order` (`sort_order`); + +-- 积分记录表索引:按类型筛选 +ALTER TABLE `points_logs` ADD INDEX IF NOT EXISTS `idx_points_logs_type` (`type`); + +-- 采购单表索引:按状态筛选、按时间排序 +ALTER TABLE `purchases` ADD INDEX IF NOT EXISTS `idx_purchases_status` (`status`); +ALTER TABLE `purchases` ADD INDEX IF NOT EXISTS `idx_purchases_created_at` (`created_at`); \ No newline at end of file diff --git a/controllers/addresses.js b/controllers/addresses.js new file mode 100644 index 0000000..00397a5 --- /dev/null +++ b/controllers/addresses.js @@ -0,0 +1,109 @@ +const { query } = require('../config/database') + +async function getAddresses(ctx) { + const userId = ctx.query.user_id + if (!userId) { + ctx.body = { code: 400, message: '缺少 user_id 参数' } + return + } + + const rows = await query( + 'SELECT * FROM addresses WHERE user_id = ? ORDER BY is_default DESC, created_at DESC', + [userId] + ) + + ctx.body = { code: 200, data: rows } +} + +async function getAddressById(ctx) { + const id = ctx.params.id + const rows = await query('SELECT * FROM addresses WHERE id = ?', [id]) + + if (rows.length > 0) { + ctx.body = { code: 200, data: rows[0] } + } else { + ctx.body = { code: 404, message: '地址不存在' } + } +} + +async function createAddress(ctx) { + const { user_id, name, phone, region, detail, is_default = 0 } = ctx.request.body + + if (!user_id || !name || !phone || !detail) { + ctx.body = { code: 400, message: '缺少必填参数' } + return + } + + if (is_default) { + await query('UPDATE addresses SET is_default = 0 WHERE user_id = ?', [user_id]) + } + + const result = await query( + 'INSERT INTO addresses (user_id, name, phone, region, detail, is_default) VALUES (?, ?, ?, ?, ?, ?)', + [user_id, name, phone, region || '', detail, is_default ? 1 : 0] + ) + + ctx.body = { code: 200, data: { id: result.insertId } } +} + +async function updateAddress(ctx) { + const id = ctx.params.id + const updates = ctx.request.body + + const current = await query('SELECT * FROM addresses WHERE id = ?', [id]) + if (!current.length) { + ctx.body = { code: 404, message: '地址不存在' } + return + } + + if (updates.is_default) { + await query('UPDATE addresses SET is_default = 0 WHERE user_id = ?', [current[0].user_id]) + } + + const fields = [] + const params = [] + for (const key of ['name', 'phone', 'region', 'detail', 'is_default']) { + if (updates[key] !== undefined) { + fields.push(`${key} = ?`) + params.push(key === 'is_default' ? (updates[key] ? 1 : 0) : updates[key]) + } + } + + if (fields.length > 0) { + params.push(id) + await query(`UPDATE addresses SET ${fields.join(', ')} WHERE id = ?`, params) + } + + const updated = await query('SELECT * FROM addresses WHERE id = ?', [id]) + ctx.body = { code: 200, data: updated[0] } +} + +async function deleteAddress(ctx) { + const id = ctx.params.id + await query('DELETE FROM addresses WHERE id = ?', [id]) + ctx.body = { code: 200, message: '删除成功' } +} + +async function setDefault(ctx) { + const id = ctx.params.id + const rows = await query('SELECT * FROM addresses WHERE id = ?', [id]) + + if (!rows.length) { + ctx.body = { code: 404, message: '地址不存在' } + return + } + + await query('UPDATE addresses SET is_default = 0 WHERE user_id = ?', [rows[0].user_id]) + await query('UPDATE addresses SET is_default = 1 WHERE id = ?', [id]) + + ctx.body = { code: 200, message: '设置成功' } +} + +module.exports = { + getAddresses, + getAddressById, + createAddress, + updateAddress, + deleteAddress, + setDefault +} diff --git a/controllers/categories.js b/controllers/categories.js index 3066a9e..e0286ae 100644 --- a/controllers/categories.js +++ b/controllers/categories.js @@ -26,7 +26,7 @@ async function getCategoryById(ctx) { } async function createCategory(ctx) { - const { name, icon, sortOrder = 0 } = ctx.request.body + const { name, icon, color, sortOrder = 0 } = ctx.request.body if (!name) { ctx.body = { @@ -37,20 +37,20 @@ async function createCategory(ctx) { } const result = await query( - 'INSERT INTO categories (name, icon, sort_order) VALUES (?, ?, ?)', - [name, icon || '', parseInt(sortOrder)] + 'INSERT INTO categories (name, icon, color, sort_order) VALUES (?, ?, ?, ?)', + [name, icon || '', color || '#1890ff', parseInt(sortOrder)] ) ctx.body = { code: 200, message: '添加成功', - data: { id: result.insertId, name, icon, sort_order: sortOrder } + data: { id: result.insertId, name, icon, color, sort_order: sortOrder } } } async function updateCategory(ctx) { const categoryId = parseInt(ctx.params.id) - const { name, icon, sortOrder, isShow } = ctx.request.body + const { name, icon, color, sortOrder, isShow } = ctx.request.body if (!name) { ctx.body = { @@ -61,8 +61,8 @@ async function updateCategory(ctx) { } const result = await query( - 'UPDATE categories SET name = ?, icon = ?, sort_order = ?, is_show = ? WHERE id = ?', - [name, icon || '', parseInt(sortOrder) || 0, parseInt(isShow) || 1, categoryId] + 'UPDATE categories SET name = ?, icon = ?, color = ?, sort_order = ?, is_show = ? WHERE id = ?', + [name, icon || '', color || '#1890ff', parseInt(sortOrder) || 0, parseInt(isShow) || 1, categoryId] ) if (result.affectedRows > 0) { diff --git a/controllers/export.js b/controllers/export.js new file mode 100644 index 0000000..20fe4bb --- /dev/null +++ b/controllers/export.js @@ -0,0 +1,87 @@ +const { query } = require('../config/database') + +function toCSV(headers, rows) { + const escape = (v) => { + if (v === null || v === undefined) return '' + const s = String(v) + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s + } + const lines = [headers.map(escape).join(',')] + for (const row of rows) { + lines.push(headers.map(h => escape(row[h])).join(',')) + } + return lines.join('\n') +} + +async function exportGoods(ctx) { + const rows = await query( + 'SELECT g.id, g.name, g.price, g.original_price, g.unit, g.stock, g.sales, g.pricing_type, c.name as category_name FROM goods g LEFT JOIN categories c ON g.category_id = c.id ORDER BY g.id ASC' + ) + const csv = toCSV( + ['id', 'name', 'price', 'original_price', 'unit', 'stock', 'sales', 'category_name'], + rows + ) + ctx.set('Content-Type', 'text/csv; charset=utf-8') + ctx.set('Content-Disposition', 'attachment; filename="goods.csv"') + ctx.body = '\uFEFF' + csv +} + +async function exportOrders(ctx) { + const statusMap = { pending: '待付款', paid: '已付款', completed: '已完成', cancelled: '已取消' } + const rows = await query( + 'SELECT o.id, o.total_price, o.status, o.created_at, u.phone as user_phone FROM orders o LEFT JOIN users u ON o.user_id = u.id ORDER BY o.created_at DESC' + ) + const mapped = rows.map(r => ({ + ...r, + status: statusMap[r.status] || r.status, + total_price: parseFloat(r.total_price) + })) + const csv = toCSV( + ['id', 'total_price', 'status', 'created_at', 'user_phone'], + mapped + ) + ctx.set('Content-Type', 'text/csv; charset=utf-8') + ctx.set('Content-Disposition', 'attachment; filename="orders.csv"') + ctx.body = '\uFEFF' + csv +} + +async function exportStock(ctx) { + const rows = await query( + 'SELECT g.id, g.name, g.price, COALESCE(s.quantity, 0) as quantity FROM goods g LEFT JOIN stock s ON g.id = s.goods_id ORDER BY quantity ASC' + ) + const csv = toCSV( + ['id', 'name', 'price', 'quantity'], + rows + ) + ctx.set('Content-Type', 'text/csv; charset=utf-8') + ctx.set('Content-Disposition', 'attachment; filename="stock.csv"') + ctx.body = '\uFEFF' + csv +} + +async function exportPurchases(ctx) { + const rows = await query( + 'SELECT p.id, p.supplier_name, p.total, p.status, p.created_at FROM purchases p ORDER BY p.created_at DESC' + ) + const mapped = rows.map(r => ({ + ...r, + status: r.status === 0 ? '待入库' : r.status === 1 ? '已入库' : String(r.status), + total: parseFloat(r.total) + })) + const csv = toCSV( + ['id', 'supplier_name', 'total', 'status', 'created_at'], + mapped + ) + ctx.set('Content-Type', 'text/csv; charset=utf-8') + ctx.set('Content-Disposition', 'attachment; filename="purchases.csv"') + ctx.body = '\uFEFF' + csv +} + +module.exports = { + exportGoods, + exportOrders, + exportStock, + exportPurchases +} diff --git a/controllers/goods-specs.js b/controllers/goods-specs.js new file mode 100644 index 0000000..d5e5b41 --- /dev/null +++ b/controllers/goods-specs.js @@ -0,0 +1,83 @@ +const { query, transaction } = require('../config/database') + +async function getSpecs(ctx) { + const goodsId = ctx.query.goods_id + if (!goodsId) { + ctx.body = { code: 400, message: '缺少 goods_id 参数' } + return + } + + const rows = await query('SELECT * FROM goods_specs WHERE goods_id = ? ORDER BY price ASC', [goodsId]) + ctx.body = { code: 200, data: rows } +} + +async function createSpec(ctx) { + const { goods_id, spec_name, price, stock = 0 } = ctx.request.body + + if (!goods_id || !spec_name || price === undefined) { + ctx.body = { code: 400, message: '缺少必填参数' } + return + } + + const result = await query( + 'INSERT INTO goods_specs (goods_id, spec_name, price, stock) VALUES (?, ?, ?, ?)', + [goods_id, spec_name, price, stock] + ) + + ctx.body = { code: 200, data: { id: result.insertId } } +} + +async function updateSpec(ctx) { + const id = ctx.params.id + const { spec_name, price, stock } = ctx.request.body + + const fields = [] + const params = [] + if (spec_name !== undefined) { fields.push('spec_name = ?'); params.push(spec_name) } + if (price !== undefined) { fields.push('price = ?'); params.push(price) } + if (stock !== undefined) { fields.push('stock = ?'); params.push(stock) } + + if (fields.length > 0) { + params.push(id) + await query(`UPDATE goods_specs SET ${fields.join(', ')} WHERE id = ?`, params) + } + + ctx.body = { code: 200, message: '更新成功' } +} + +async function deleteSpec(ctx) { + const id = ctx.params.id + await query('DELETE FROM goods_specs WHERE id = ?', [id]) + ctx.body = { code: 200, message: '删除成功' } +} + +async function batchSave(ctx) { + const { goods_id, specs } = ctx.request.body + + if (!goods_id || !Array.isArray(specs)) { + ctx.body = { code: 400, message: '参数错误' } + return + } + + await transaction(async (conn) => { + await conn.execute('DELETE FROM goods_specs WHERE goods_id = ?', [goods_id]) + for (const spec of specs) { + if (spec.spec_name && spec.price !== undefined) { + await conn.execute( + 'INSERT INTO goods_specs (goods_id, spec_name, price, stock) VALUES (?, ?, ?, ?)', + [goods_id, spec.spec_name, spec.price, spec.stock || 0] + ) + } + } + }) + + ctx.body = { code: 200, message: '保存成功' } +} + +module.exports = { + getSpecs, + createSpec, + updateSpec, + deleteSpec, + batchSave +} diff --git a/controllers/goods.js b/controllers/goods.js index 154f559..ffb963c 100644 --- a/controllers/goods.js +++ b/controllers/goods.js @@ -1,5 +1,23 @@ const { query } = require('../config/database') -const { toRelativeUrl } = require('../utils/image-url') +const { toRelativeUrl, processGoodsImages } = require('../utils/image-url') +const { paginate } = require('../utils/pagination') +const path = require('path') +const fs = require('fs') + +function parseImages(images) { + if (!images) return [] + try { + const parsed = typeof images === 'string' ? JSON.parse(images) : images + return Array.isArray(parsed) ? parsed : [] + } catch { return [] } +} + +function deleteImageFiles(urls) { + for (const url of urls) { + const filePath = path.join(__dirname, '..', 'public', url.replace(/^\//, '')) + fs.unlink(filePath, () => {}) + } +} async function getGoods(ctx) { let sql = 'SELECT * FROM goods WHERE 1=1' @@ -37,16 +55,20 @@ async function getGoods(ctx) { sql += ' ORDER BY id DESC' } - if (ctx.query.limit) { + if (ctx.query.limit && !ctx.query.page) { sql += ' LIMIT ?' params.push(parseInt(ctx.query.limit)) + const goods = await query(sql, params) + ctx.body = { code: 200, data: processGoodsImages(goods) } + return } - const goods = await query(sql, params) + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) + if (result.data) result.data = processGoodsImages(result.data) ctx.body = { code: 200, - data: goods + ...result } } @@ -57,7 +79,7 @@ async function getGoodsById(ctx) { if (goods.length > 0) { ctx.body = { code: 200, - data: goods[0] + data: processGoodsImages(goods)[0] } } else { ctx.body = { @@ -129,7 +151,6 @@ async function updateGoods(ctx) { return } - // 将图片URL转换为相对路径存储 const relativeImages = (images || []).map(img => toRelativeUrl(img)) const sql = `UPDATE goods SET @@ -153,8 +174,14 @@ async function updateGoods(ctx) { ] try { + const existing = await query('SELECT images FROM goods WHERE id = ?', [goodsId]) const result = await query(sql, params) if (result.affectedRows > 0) { + if (existing.length > 0 && images) { + const oldImages = parseImages(existing[0].images) + const oldFiles = oldImages.filter(u => u.startsWith('/uploads/') && !relativeImages.includes(u)) + deleteImageFiles(oldFiles) + } ctx.body = { code: 200, message: '更新成功' @@ -178,8 +205,13 @@ async function deleteGoods(ctx) { const goodsId = parseInt(ctx.params.id) try { + const existing = await query('SELECT images FROM goods WHERE id = ?', [goodsId]) const result = await query('DELETE FROM goods WHERE id = ?', [goodsId]) if (result.affectedRows > 0) { + if (existing.length > 0) { + const oldImages = parseImages(existing[0].images) + deleteImageFiles(oldImages.filter(u => u.startsWith('/uploads/'))) + } ctx.body = { code: 200, message: '删除成功' diff --git a/controllers/orders.js b/controllers/orders.js index cff7380..1a69f8d 100644 --- a/controllers/orders.js +++ b/controllers/orders.js @@ -1,22 +1,37 @@ -const { query } = require('../config/database') +const { query, transaction } = require('../config/database') +const { paginate } = require('../utils/pagination') +const orderService = require('../services/orderService') async function getOrders(ctx) { - const orders = await query('SELECT * FROM orders ORDER BY created_at DESC') - + const { page, pageSize, status } = ctx.query + let sql = 'SELECT * FROM orders WHERE 1=1' + const params = [] + if (status) { + sql += ' AND status = ?' + params.push(status) + } + sql += ' ORDER BY created_at DESC' + const result = await paginate(query, sql, params, page, pageSize) + + const rows = result.data || [] + await orderService.attachOrderItems(rows) + ctx.body = { code: 200, - data: orders + ...result } } async function getOrderById(ctx) { const orderId = ctx.params.id const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId]) - + if (orders.length > 0) { + const order = orders[0] + order.items = await orderService.getOrderItems(orderId) ctx.body = { code: 200, - data: orders[0] + data: order } } else { ctx.body = { @@ -27,59 +42,88 @@ async function getOrderById(ctx) { } async function createOrder(ctx) { - const { totalPrice, cart, userInfo } = ctx.request.body - - const newOrder = { - id: `order_${Date.now()}_${Math.floor(Math.random() * 1000)}`, - status: 'pending', - total_price: totalPrice, - cart: typeof cart === 'string' ? cart : JSON.stringify(cart), - user_info: typeof userInfo === 'string' ? userInfo : JSON.stringify(userInfo || {}) + const { id, userId, totalPrice, cart, remark, customerName, customerPhone, orderType, status } = ctx.request.body + + const orderId = id || `order_${Date.now()}_${Math.floor(Math.random() * 1000)}` + + const userInfo = JSON.stringify({ + remark: remark || '', + customerName: customerName || '', + customerPhone: customerPhone || '', + orderType: orderType || 'customer' + }) + + await transaction(async (conn) => { + await conn.execute( + 'INSERT INTO orders (id, user_id, status, total_price, cart, user_info) VALUES (?, ?, ?, ?, ?, ?)', + [orderId, userId || null, status || 'pending', totalPrice, typeof cart === 'string' ? cart : JSON.stringify(cart), userInfo] + ) + await orderService.insertOrderItems(conn, orderId, cart) + }) + + const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId]) + const created = orders[0] + created.items = await orderService.getOrderItems(orderId) + + if (created.status === 'completed') { + setImmediate(() => orderService.processOrderComplete(created)) } - - await query( - 'INSERT INTO orders (id, status, total_price, cart, user_info) VALUES (?, ?, ?, ?, ?)', - [newOrder.id, newOrder.status, newOrder.total_price, newOrder.cart, newOrder.user_info] - ) - + ctx.body = { code: 200, - data: newOrder + data: created } } async function updateOrder(ctx) { const orderId = ctx.params.id const updates = ctx.request.body - + const orders = await query('SELECT * FROM orders WHERE id = ?', [orderId]) - + if (orders.length > 0) { + const prevStatus = orders[0].status const updateFields = [] const updateParams = [] - + if (updates.status !== undefined) { updateFields.push('status = ?') updateParams.push(updates.status) } - if (updates.total_price !== undefined) { + const totalPrice = updates.total_price !== undefined ? updates.total_price : updates.totalPrice + if (totalPrice !== undefined) { updateFields.push('total_price = ?') - updateParams.push(updates.total_price) + updateParams.push(totalPrice) } if (updates.cart !== undefined) { + const cartStr = typeof updates.cart === 'string' ? updates.cart : JSON.stringify(updates.cart) updateFields.push('cart = ?') - updateParams.push(typeof updates.cart === 'string' ? updates.cart : JSON.stringify(updates.cart)) + updateParams.push(cartStr) } - - updateParams.push(orderId) - - await query(`UPDATE orders SET ${updateFields.join(', ')} WHERE id = ?`, updateParams) - + + if (updateFields.length > 0) { + updateParams.push(orderId) + await query(`UPDATE orders SET ${updateFields.join(', ')} WHERE id = ?`, updateParams) + } + + if (updates.cart !== undefined) { + await transaction(async (conn) => { + await conn.execute('DELETE FROM order_items WHERE order_id = ?', [orderId]) + await orderService.insertOrderItems(conn, orderId, updates.cart) + }) + } + const updatedOrders = await query('SELECT * FROM orders WHERE id = ?', [orderId]) - + const completed = updatedOrders[0] + completed.items = await orderService.getOrderItems(orderId) + + if (completed.status === 'completed' && prevStatus !== 'completed') { + setImmediate(() => orderService.processOrderComplete(completed)) + } + ctx.body = { code: 200, - data: updatedOrders[0] + data: completed } } else { ctx.body = { @@ -94,4 +138,4 @@ module.exports = { getOrderById, createOrder, updateOrder -} \ No newline at end of file +} diff --git a/controllers/points-goods.js b/controllers/points-goods.js index 06a9d09..ab891ec 100644 --- a/controllers/points-goods.js +++ b/controllers/points-goods.js @@ -1,4 +1,5 @@ -const { query } = require('../config/database') +const { query, transaction } = require('../config/database') +const { paginate } = require('../utils/pagination') async function getPointsGoods(ctx) { let sql = 'SELECT * FROM points_goods WHERE 1=1' @@ -9,6 +10,13 @@ async function getPointsGoods(ctx) { } sql += ' ORDER BY points ASC' + + if (ctx.query.page) { + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) + ctx.body = { code: 200, ...result } + return + } + const goods = await query(sql, params) ctx.body = { @@ -115,14 +123,15 @@ async function exchangePointsGoods(ctx) { return } - const newPoints = user.points - totalPoints - await query('UPDATE users SET points = ? WHERE id = ?', [newPoints, userId]) - await query('UPDATE points_goods SET stock = stock - ? WHERE id = ?', [qty, goodsId]) - - await query( - 'INSERT INTO points_logs (user_id, type, amount, description) VALUES (?, ?, ?, ?)', - [userId, 'spend', totalPoints, `兑换「${goodsItem.name}」x${qty}`] - ) + await transaction(async (conn) => { + const newPoints = user.points - totalPoints + await conn.execute('UPDATE users SET points = ? WHERE id = ?', [newPoints, userId]) + await conn.execute('UPDATE points_goods SET stock = stock - ? WHERE id = ?', [qty, goodsId]) + await conn.execute( + 'INSERT INTO points_logs (user_id, type, amount, description) VALUES (?, ?, ?, ?)', + [userId, 'spend', totalPoints, `兑换「${goodsItem.name}」x${qty}`] + ) + }) ctx.body = { code: 200, diff --git a/controllers/purchases.js b/controllers/purchases.js index a8b6067..f78399f 100644 --- a/controllers/purchases.js +++ b/controllers/purchases.js @@ -1,4 +1,5 @@ -const { query } = require('../config/database') +const { query, transaction } = require('../config/database') +const { paginate } = require('../utils/pagination') async function getPurchases(ctx) { let sql = ` @@ -14,11 +15,11 @@ async function getPurchases(ctx) { } sql += ' ORDER BY p.created_at DESC' - const purchases = await query(sql, params) + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) ctx.body = { code: 200, - data: purchases + ...result } } @@ -69,26 +70,28 @@ async function createPurchase(ctx) { total += (item.purchase_price || 0) * (item.quantity || 0) } - const purchaseResult = await query( - 'INSERT INTO purchases (supplier_id, supplier_name, total, remarks) VALUES (?, ?, ?, ?)', - [supplier_id, supplier.name, total, remarks || ''] - ) - - const purchaseId = purchaseResult.insertId - - for (const item of items) { - const goods = await query('SELECT name FROM goods WHERE id = ?', [item.goods_id]) - const goodsName = goods.length > 0 ? goods[0].name : '' - await query( - 'INSERT INTO purchase_items (purchase_id, goods_id, goods_name, quantity, purchase_price) VALUES (?, ?, ?, ?, ?)', - [purchaseId, item.goods_id, goodsName, item.quantity || 0, item.purchase_price || 0] + const result = await transaction(async (conn) => { + const purchaseResult = await conn.execute( + 'INSERT INTO purchases (supplier_id, supplier_name, total, remarks) VALUES (?, ?, ?, ?)', + [supplier_id, supplier.name, total, remarks || ''] ) - } + const purchaseId = purchaseResult[0].insertId + + for (const item of items) { + const goods = await conn.execute('SELECT name FROM goods WHERE id = ?', [item.goods_id]) + const goodsName = goods[0].length > 0 ? goods[0][0].name : '' + await conn.execute( + 'INSERT INTO purchase_items (purchase_id, goods_id, goods_name, quantity, purchase_price) VALUES (?, ?, ?, ?, ?)', + [purchaseId, item.goods_id, goodsName, item.quantity || 0, item.purchase_price || 0] + ) + } + return purchaseId + }) ctx.body = { code: 200, message: '采购单创建成功', - data: { id: purchaseId } + data: { id: result } } } @@ -109,26 +112,27 @@ async function inboundPurchase(ctx) { const items = await query('SELECT * FROM purchase_items WHERE purchase_id = ?', [id]) - for (const item of items) { - const existing = await query('SELECT * FROM stock WHERE goods_id = ?', [item.goods_id]) - if (existing.length > 0) { - await query( - 'UPDATE stock SET quantity = quantity + ? WHERE goods_id = ?', + await transaction(async (conn) => { + for (const item of items) { + const existing = await conn.execute('SELECT * FROM stock WHERE goods_id = ?', [item.goods_id]) + if (existing[0].length > 0) { + await conn.execute( + 'UPDATE stock SET quantity = quantity + ? WHERE goods_id = ?', + [item.quantity, item.goods_id] + ) + } else { + await conn.execute( + 'INSERT INTO stock (goods_id, quantity, warehouse) VALUES (?, ?, ?)', + [item.goods_id, item.quantity, '默认仓库'] + ) + } + await conn.execute( + 'UPDATE goods SET stock = stock + ? WHERE id = ?', [item.quantity, item.goods_id] ) - } else { - await query( - 'INSERT INTO stock (goods_id, quantity, warehouse) VALUES (?, ?, ?)', - [item.goods_id, item.quantity, '默认仓库'] - ) } - await query( - 'UPDATE goods SET stock = stock + ? WHERE id = ?', - [item.quantity, item.goods_id] - ) - } - - await query('UPDATE purchases SET status = 1 WHERE id = ?', [id]) + await conn.execute('UPDATE purchases SET status = 1 WHERE id = ?', [id]) + }) ctx.body = { code: 200, diff --git a/controllers/recognize.js b/controllers/recognize.js new file mode 100644 index 0000000..d5632a7 --- /dev/null +++ b/controllers/recognize.js @@ -0,0 +1,78 @@ +const { query } = require('../config/database') +const fetch = require('node-fetch') +const { AI_VL_MODEL, AI_RECOGNIZE_LIMIT } = require('../config/constants') + +const AI_API_KEY = process.env.DASHSCOPE_API_KEY +const AI_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' + +async function getByBarcode(ctx) { + try { + const { barcode } = ctx.request.body + if (!barcode) { + ctx.body = { code: 400, message: '请提供条形码' } + return + } + const goods = await query('SELECT * FROM goods WHERE barcode = ? LIMIT 1', [barcode]) + if (goods.length > 0) { + ctx.body = { code: 200, data: goods[0] } + } else { + ctx.body = { code: 404, message: '未找到该商品' } + } + } catch (error) { + console.error('条码查询失败:', error) + ctx.body = { code: 500, message: '查询失败' } + } +} + +async function recognizeImage(ctx) { + try { + const { imageData } = ctx.request.body + if (!imageData) { + ctx.body = { code: 400, message: '请提供图片数据' } + return + } + if (!AI_API_KEY) { + ctx.body = { code: 500, message: 'AI 识别未配置' } + return + } + + const response = await fetch(AI_API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${AI_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: AI_VL_MODEL, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: '请识别这张图片中的商品,返回商品名称。只返回名称,不要其他内容。' }, + { type: 'image_url', image_url: { url: imageData } } + ] + } + ] + }) + }) + + const result = await response.json() + const name = result?.choices?.[0]?.message?.content?.trim() || '' + const goods = name + ? await query('SELECT * FROM goods WHERE name LIKE ? LIMIT ?', [`%${name}%`, AI_RECOGNIZE_LIMIT]) + : [] + + ctx.body = { + code: 200, + data: { + message: name ? `识别到: ${name}` : '未识别到商品', + goods: goods || [] + } + } + } catch (error) { + console.error('图片识别失败:', error) + ctx.body = { code: 500, message: '识别失败' } + } +} + +module.exports = { getByBarcode, recognizeImage } diff --git a/controllers/reports.js b/controllers/reports.js new file mode 100644 index 0000000..5ec95f2 --- /dev/null +++ b/controllers/reports.js @@ -0,0 +1,131 @@ +const { query } = require('../config/database') +const { COST_RATIO, PROFIT_RATIO } = require('../config/constants') + +async function getSalesTrend(ctx) { + const days = parseInt(ctx.query.days) || 30 + const group = ctx.query.group || 'day' + + let dateFormat + if (group === 'week') { + dateFormat = 'DATE_FORMAT(created_at, \'%x-W%v\')' + } else if (group === 'month') { + dateFormat = 'DATE_FORMAT(created_at, \'%Y-%m\')' + } else { + dateFormat = 'DATE(created_at)' + } + + const rows = await query( + `SELECT ${dateFormat} as period, + COUNT(*) as order_count, + COALESCE(SUM(total_price), 0) as total_sales + FROM orders + WHERE status IN ('paid', 'completed') + AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY period + ORDER BY period ASC`, + [days] + ) + + ctx.body = { code: 200, data: rows } +} + +async function getHotProducts(ctx) { + const limit = parseInt(ctx.query.limit) || 10 + + const rows = await query( + `SELECT id, name, price, sales, stock, + COALESCE(s.quantity, 0) as stock_qty + FROM goods + LEFT JOIN stock s ON goods.id = s.goods_id + ORDER BY sales DESC + LIMIT ?`, + [limit] + ) + + ctx.body = { code: 200, data: rows } +} + +async function getProfitAnalysis(ctx) { + const days = parseInt(ctx.query.days) || 30 + + const revenueRows = await query( + `SELECT DATE(created_at) as date, + COUNT(*) as order_count, + COALESCE(SUM(total_price), 0) as revenue + FROM orders + WHERE status IN ('paid', 'completed') + AND created_at >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY DATE(created_at) + ORDER BY date ASC`, + [days] + ) + + const avgCost = await query( + `SELECT COALESCE(AVG(purchase_price), 0) as avg_cost + FROM purchase_items` + ) + + const costPerUnit = parseFloat(avgCost[0]?.avg_cost || 0) + + const enriched = revenueRows.map(row => ({ + ...row, + revenue: parseFloat(row.revenue), + estimated_cost: parseFloat(row.revenue) * COST_RATIO, + estimated_profit: parseFloat(row.revenue) * PROFIT_RATIO + })) + + const totalRevenue = enriched.reduce((s, r) => s + r.revenue, 0) + const totalCost = enriched.reduce((s, r) => s + r.estimated_cost, 0) + const totalProfit = enriched.reduce((s, r) => s + r.estimated_profit, 0) + + ctx.body = { + code: 200, + data: { + days, + summary: { + total_revenue: totalRevenue, + total_cost: totalCost, + total_profit: totalProfit, + profit_margin: totalRevenue > 0 ? ((totalProfit / totalRevenue) * 100).toFixed(1) : '0.0', + avg_purchase_cost: costPerUnit + }, + details: enriched + } + } +} + +async function getInventoryTurnover(ctx) { + const rows = await query( + `SELECT g.id, g.name, g.price, g.sales, + COALESCE(s.quantity, 0) as stock_qty, + CASE + WHEN COALESCE(s.quantity, 0) <= 0 THEN g.sales + ELSE ROUND(g.sales / s.quantity, 2) + END as turnover_ratio + FROM goods g + LEFT JOIN stock s ON g.id = s.goods_id + ORDER BY turnover_ratio DESC` + ) + + const lowStock = rows.filter(r => r.stock_qty <= 5) + const outOfStock = rows.filter(r => r.stock_qty <= 0) + const slowMoving = rows.filter(r => r.turnover_ratio < 0.1 && r.sales > 0) + + ctx.body = { + code: 200, + data: { + total_items: rows.length, + low_stock_count: lowStock.length, + out_of_stock_count: outOfStock.length, + slow_moving_count: slowMoving.length, + items: rows + } + } +} + +module.exports = { + getSalesTrend, + getHotProducts, + getProfitAnalysis, + getInventoryTurnover +} diff --git a/controllers/stock.js b/controllers/stock.js index 49ef6a3..3babcb4 100644 --- a/controllers/stock.js +++ b/controllers/stock.js @@ -1,4 +1,5 @@ const { query } = require('../config/database') +const { paginate } = require('../utils/pagination') // 获取库存列表 async function getStockList(ctx) { @@ -32,11 +33,11 @@ async function getStockList(ctx) { sql += ' ORDER BY s.quantity ASC' - const stockList = await query(sql, params) + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) ctx.body = { code: 200, - data: stockList + ...result } } diff --git a/controllers/subscribe.js b/controllers/subscribe.js new file mode 100644 index 0000000..413fdb9 --- /dev/null +++ b/controllers/subscribe.js @@ -0,0 +1,37 @@ +const { query } = require('../config/database') + +async function bindOpenId(ctx) { + const { userId, openid } = ctx.request.body + if (!userId || !openid) { + ctx.body = { code: 400, message: '缺少参数' } + return + } + await query('UPDATE users SET openid = ? WHERE id = ?', [openid, userId]) + ctx.body = { code: 200, message: '绑定成功' } +} + +async function notifyOrder(ctx) { + const { orderId } = ctx.request.body + const orders = await query( + 'SELECT o.*, u.openid FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.id = ?', + [orderId] + ) + if (!orders.length) { + ctx.body = { code: 404, message: '订单不存在' } + return + } + const order = orders[0] + if (!order.openid) { + ctx.body = { code: 400, message: '用户未绑定微信' } + return + } + try { + const { sendOrderStatusNotification } = require('../utils/wechat') + const result = await sendOrderStatusNotification(order.openid, order.id, order.status, order.total_price) + ctx.body = { code: 200, data: result } + } catch (e) { + ctx.body = { code: 500, message: e.message } + } +} + +module.exports = { bindOpenId, notifyOrder } diff --git a/controllers/suppliers.js b/controllers/suppliers.js index b3203ac..4d78a97 100644 --- a/controllers/suppliers.js +++ b/controllers/suppliers.js @@ -1,4 +1,5 @@ const { query } = require('../config/database') +const { paginate } = require('../utils/pagination') async function getSuppliers(ctx) { let sql = 'SELECT * FROM suppliers WHERE 1=1' @@ -10,11 +11,11 @@ async function getSuppliers(ctx) { } sql += ' ORDER BY id DESC' - const suppliers = await query(sql, params) + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) ctx.body = { code: 200, - data: suppliers + ...result } } diff --git a/controllers/users.js b/controllers/users.js index 96e84b0..1016de2 100644 --- a/controllers/users.js +++ b/controllers/users.js @@ -1,5 +1,7 @@ const { query } = require('../config/database') const crypto = require('crypto') +const { paginate } = require('../utils/pagination') +const { DEFAULT_PASSWORD } = require('../config/constants') function md5(str) { return crypto.createHash('md5').update(str).digest('hex') @@ -59,6 +61,7 @@ async function login(ctx) { } const token = generateToken() + await query('UPDATE users SET token = ? WHERE id = ?', [token, user.id]) const userInfo = { id: user.id, @@ -79,129 +82,113 @@ async function login(ctx) { // 用户注册(普通用户) async function register(ctx) { const { phone, password, name } = ctx.request.body - if (!phone || !password || !name) { - ctx.body = { - code: 400, - message: '请填写完整信息' - } + ctx.body = { code: 400, message: '请填写完整信息' } return } - + const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) - if (existing.length > 0) { - ctx.body = { - code: 400, - message: '该手机号已注册' - } + ctx.body = { code: 400, message: '该手机号已注册' } return } - + const result = await query( 'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)', [phone, md5(password), name, '', 0, 0] ) - + ctx.body = { code: 200, message: '注册成功', + data: { id: result.insertId, phone, name, avatar: '', points: 0, role: 0 } + } +} + +async function requireStaffAuth(ctx) { + +async function requireStaffAuth(ctx) { + const authHeader = ctx.headers.authorization || '' + const token = authHeader.replace('Bearer ', '') + if (!token) { + ctx.body = { code: 401, message: '未登录,请先登录店员账号' } + return null + } + const operators = await query('SELECT * FROM users WHERE token = ? AND role = 1 AND status = 1', [token]) + if (operators.length === 0) { + ctx.body = { code: 401, message: '权限不足,仅店员可操作' } + return null + } + return operators[0] +} + +async function createUser(phone, name, role) { + const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) + if (existing.length > 0) return { conflict: true } + + const result = await query( + 'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)', + [phone, md5(DEFAULT_PASSWORD), name, '', 0, role] + ) + + return { + conflict: false, data: { id: result.insertId, phone, name, avatar: '', points: 0, - role: 0 + role } } } -// 店员注册(由店员帮助用户注册或店员自己注册) +// 店员注册(需要店员权限) async function registerStaff(ctx) { const { phone, name } = ctx.request.body - if (!phone || !name) { - ctx.body = { - code: 400, - message: '请填写手机号和姓名' - } + ctx.body = { code: 400, message: '请填写手机号和姓名' } return } - - const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) - - if (existing.length > 0) { - ctx.body = { - code: 400, - message: '该手机号已注册' - } + + const operator = await requireStaffAuth(ctx) + if (!operator) return + + const result = await createUser(phone, name, 1) + if (result.conflict) { + ctx.body = { code: 400, message: '该手机号已注册' } return } - - // 默认密码 123456 - const defaultPassword = '123456' - - const result = await query( - 'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)', - [phone, md5(defaultPassword), name, '', 0, 1] - ) - + ctx.body = { code: 200, - message: '店员注册成功,默认密码为123456', - data: { - id: result.insertId, - phone, - name, - avatar: '', - points: 0, - role: 1 - } + message: `店员注册成功,默认密码为${DEFAULT_PASSWORD}`, + data: result.data } } -// 店员帮助用户注册 +// 店员帮助用户注册(需要店员权限) async function registerByStaff(ctx) { const { phone, name } = ctx.request.body - if (!phone || !name) { - ctx.body = { - code: 400, - message: '请填写手机号和姓名' - } + ctx.body = { code: 400, message: '请填写手机号和姓名' } return } - - const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) - - if (existing.length > 0) { - ctx.body = { - code: 400, - message: '该手机号已注册' - } + + const operator = await requireStaffAuth(ctx) + if (!operator) return + + const result = await createUser(phone, name, 0) + if (result.conflict) { + ctx.body = { code: 400, message: '该手机号已注册' } return } - - // 默认密码 123456 - const defaultPassword = '123456' - - const result = await query( - 'INSERT INTO users (phone, password, name, avatar, points, role) VALUES (?, ?, ?, ?, ?, ?)', - [phone, md5(defaultPassword), name, '', 0, 0] - ) - + ctx.body = { code: 200, - message: '用户注册成功,默认密码为123456', - data: { - id: result.insertId, - phone, - name, - avatar: '', - points: 0, - role: 0 - } + message: `用户注册成功,默认密码为${DEFAULT_PASSWORD}`, + data: result.data } } @@ -210,18 +197,7 @@ async function getUserInfo(ctx) { const userId = parseInt(ctx.query.id) if (!userId) { - // 返回默认用户信息 - ctx.body = { - code: 200, - data: { - id: 1, - phone: '13800138000', - name: '张三', - avatar: '', - points: 1000, - role: 0 - } - } + ctx.body = { code: 400, message: '缺少用户ID' } return } @@ -265,11 +241,11 @@ async function getUsers(ctx) { sql += ' ORDER BY created_at DESC' - const users = await query(sql, params) + const result = await paginate(query, sql, params, ctx.query.page, ctx.query.pageSize) ctx.body = { code: 200, - data: users + ...result } } @@ -394,14 +370,14 @@ async function resetPassword(ctx) { return } - const defaultPassword = '123456' - + const defaultPassword = DEFAULT_PASSWORD + const result = await query('UPDATE users SET password = ? WHERE id = ?', [md5(defaultPassword), userId]) - + if (result.affectedRows > 0) { ctx.body = { code: 200, - message: '密码已重置为123456' + message: `密码已重置为${DEFAULT_PASSWORD}` } } else { ctx.body = { diff --git a/data/categories.json b/data/categories.json deleted file mode 100644 index 00ace0a..0000000 --- a/data/categories.json +++ /dev/null @@ -1 +0,0 @@ -[{"id": 1, "name": "生鲜果蔬", "icon": "🍎", "is_show": 1, "sort_order": 1}, {"id": 2, "name": "休闲零食", "icon": "🍪", "is_show": 1, "sort_order": 2}, {"id": 3, "name": "酒水饮料", "icon": "🍺", "is_show": 1, "sort_order": 3}, {"id": 4, "name": "粮油调味", "icon": "🍚", "is_show": 1, "sort_order": 4}, {"id": 5, "name": "日用百货", "icon": "🧴", "is_show": 1, "sort_order": 5}, {"id": 10, "name": "乳制品", "icon": "🥛", "is_show": 1, "sort_order": 6}, {"id": 13, "name": "冷冻食品", "icon": "🧊", "is_show": 1, "sort_order": 7}, {"id": 37, "name": "速食方便", "icon": "🍜", "is_show": 1, "sort_order": 8}, {"id": 42, "name": "肉禽蛋品", "icon": "🥩", "is_show": 1, "sort_order": 9}, {"id": 49, "name": "海鲜水产", "icon": "🦐", "is_show": 1, "sort_order": 10}, {"id": 11, "name": "烘焙糕点", "icon": "🍰", "is_show": 0, "sort_order": 11}, {"id": 12, "name": "保健品", "icon": "💊", "is_show": 0, "sort_order": 12}] \ No newline at end of file diff --git a/data/goods.json b/data/goods.json deleted file mode 100644 index 9f76ab4..0000000 --- a/data/goods.json +++ /dev/null @@ -1 +0,0 @@ -[{"id": 1, "name": "红富士苹果", "price": "12.80", "original_price": "15.80", "unit": "斤", "category_id": 1, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=red%20fuji%20apple%20fresh%20fruit%20on%20white%20background&image_size=square\"]", "stock": 100, "sales": 2341, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "新鲜红富士苹果,脆甜多汁"}, {"id": 2, "name": "云南香蕉", "price": "5.90", "original_price": "7.90", "unit": "斤", "category_id": 1, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20yellow%20bananas%20bunch%20on%20white%20background&image_size=square\"]", "stock": 200, "sales": 1892, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "香甜软糯的云南香蕉"}, {"id": 3, "name": "进口车厘子", "price": "59.90", "original_price": "79.90", "unit": "斤", "category_id": 1, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20red%20cherries%20on%20white%20background&image_size=square\"]", "stock": 50, "sales": 876, "is_hot": 1, "is_new": 1, "pricing_type": 1, "description": "智利进口车厘子,个大饱满"}, {"id": 4, "name": "有机西兰花", "price": "6.80", "original_price": "8.80", "unit": "个", "category_id": 1, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20green%20broccoli%20on%20white%20background&image_size=square\"]", "stock": 80, "sales": 654, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "新鲜有机西兰花"}, {"id": 5, "name": "土鸡蛋", "price": "19.90", "original_price": "25.90", "unit": "盒", "category_id": 42, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20brown%20eggs%20in%20basket%20on%20white%20background&image_size=square\"]", "stock": 120, "sales": 3421, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "农家散养土鸡蛋,30枚/盒"}, {"id": 6, "name": "五花肉", "price": "28.80", "original_price": "35.80", "unit": "斤", "category_id": 42, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20pork%20belly%20slices%20on%20white%20background&image_size=square\"]", "stock": 60, "sales": 1234, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "新鲜五花肉,肥瘦相间"}, {"id": 7, "name": "厄瓜多尔白虾", "price": "45.90", "original_price": "59.90", "unit": "盒", "category_id": 49, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20white%20shrimp%20on%20ice%20on%20white%20background&image_size=square\"]", "stock": 40, "sales": 567, "is_hot": 0, "is_new": 1, "pricing_type": 1, "description": "进口白虾,肉质鲜美"}, {"id": 8, "name": "蒙牛纯牛奶", "price": "59.90", "original_price": "69.90", "unit": "箱", "category_id": 10, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=mengniu%20milk%20carton%20box%20on%20white%20background&image_size=square\"]", "stock": 200, "sales": 5678, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "蒙牛纯牛奶,250ml*24盒"}, {"id": 9, "name": "乐事薯片", "price": "9.90", "original_price": "12.90", "unit": "袋", "category_id": 2, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=lays%20potato%20chips%20bag%20on%20white%20background&image_size=square\"]", "stock": 300, "sales": 8901, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "乐事原味薯片,75g"}, {"id": 10, "name": "奥利奥饼干", "price": "8.80", "original_price": "10.80", "unit": "盒", "category_id": 2, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=oreo%20cookies%20package%20on%20white%20background&image_size=square\"]", "stock": 150, "sales": 4567, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "奥利奥夹心饼干"}, {"id": 11, "name": "可口可乐", "price": "3.50", "original_price": "4.50", "unit": "瓶", "category_id": 3, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=coca%20cola%20bottle%20on%20white%20background&image_size=square\"]", "stock": 500, "sales": 12345, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "可口可乐330ml"}, {"id": 12, "name": "农夫山泉", "price": "2.00", "original_price": "2.50", "unit": "瓶", "category_id": 3, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=nongfu%20spring%20water%20bottle%20on%20white%20background&image_size=square\"]", "stock": 1000, "sales": 23456, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "农夫山泉550ml"}, {"id": 13, "name": "金龙鱼调和油", "price": "68.00", "original_price": "78.00", "unit": "桶", "category_id": 4, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=cooking%20oil%20bottle%20on%20white%20background&image_size=square\"]", "stock": 80, "sales": 2134, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "金龙鱼调和油5L"}, {"id": 14, "name": "五常大米", "price": "45.00", "original_price": "55.00", "unit": "袋", "category_id": 4, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=rice%20bag%20on%20white%20background&image_size=square\"]", "stock": 60, "sales": 1876, "is_hot": 0, "is_new": 1, "pricing_type": 1, "description": "五常大米10kg"}, {"id": 15, "name": "维达纸巾", "price": "29.90", "original_price": "35.90", "unit": "提", "category_id": 5, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=tissue%20paper%20pack%20on%20white%20background&image_size=square\"]", "stock": 100, "sales": 3456, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "维达纸巾10包/提"}, {"id": 16, "name": "统一冰红茶", "price": "3.00", "original_price": "3.80", "unit": "瓶", "category_id": 3, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=iced%20tea%20bottle%20on%20white%20background&image_size=square\"]", "stock": 300, "sales": 6789, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "统一冰红茶500ml"}, {"id": 17, "name": "思念水饺", "price": "19.90", "original_price": "24.90", "unit": "袋", "category_id": 13, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=frozen%20dumplings%20package%20on%20white%20background&image_size=square\"]", "stock": 70, "sales": 2345, "is_hot": 0, "is_new": 0, "pricing_type": 1, "description": "思念水饺500g"}, {"id": 18, "name": "康师傅方便面", "price": "4.50", "original_price": "5.50", "unit": "桶", "category_id": 37, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=instant%20noodles%20cup%20on%20white%20background&image_size=square\"]", "stock": 200, "sales": 7890, "is_hot": 1, "is_new": 0, "pricing_type": 1, "description": "康师傅红烧牛肉面"}, {"id": 19, "name": "新鲜草莓", "price": "29.90", "original_price": "39.90", "unit": "盒", "category_id": 1, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20red%20strawberries%20in%20box%20on%20white%20background&image_size=square\"]", "stock": 45, "sales": 1234, "is_hot": 0, "is_new": 1, "pricing_type": 1, "description": "新鲜草莓250g"}, {"id": 20, "name": "鲜活鲈鱼", "price": "35.00", "original_price": "45.00", "unit": "条", "category_id": 49, "images": "[\"https://neeko-copilot.bytedance.net/api/text_to_image?prompt=fresh%20sea%20bass%20fish%20on%20white%20background&image_size=square\"]", "stock": 30, "sales": 456, "is_hot": 0, "is_new": 1, "pricing_type": 1, "description": "鲜活鲈鱼约500g"}] \ No newline at end of file diff --git a/data/users.json b/data/users.json deleted file mode 100644 index 3f37579..0000000 --- a/data/users.json +++ /dev/null @@ -1 +0,0 @@ -[{"id": 1, "phone": "13800138000", "password": "e10adc3949ba59abbe56e057f20f883e", "name": "张三", "avatar": "", "points": 1000, "created_at": "2024-01-01T00:00:00Z"}, {"id": 2, "phone": "13900139000", "password": "e10adc3949ba59abbe56e057f20f883e", "name": "李四", "avatar": "", "points": 500, "created_at": "2024-02-01T00:00:00Z"}] \ No newline at end of file diff --git a/nginx b/nginx deleted file mode 100644 index 63dd91d..0000000 --- a/nginx +++ /dev/null @@ -1,65 +0,0 @@ -server { - listen 80; - server_name donghy.top www.donghy.top; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl http2; - server_name donghy.top www.donghy.top; - - ssl_certificate /etc/nginx/ssl/donghy.top_bundle.pem; - ssl_certificate_key /etc/nginx/ssl/donghy.top.key; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; - ssl_prefer_server_ciphers off; - - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 10m; - ssl_session_tickets off; - - ssl_stapling on; - ssl_stapling_verify on; - - resolver 8.8.8.8 8.8.4.4 valid=300s; - resolver_timeout 5s; - - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header X-Frame-Options DENY; - add_header X-Content-Type-Options nosniff; - - keepalive_timeout 65; - keepalive_requests 100; - - tcp_nodelay on; - tcp_nopush on; - - location /img/ { - alias /var/www/node/services/public/uploads/; - expires 30d; - add_header Cache-Control "public, immutable"; - add_header Access-Control-Allow-Origin *; - } - - location /api/ { - proxy_pass http://127.0.0.1:3006/api/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - proxy_buffer_size 4k; - proxy_buffers 4 32k; - proxy_busy_buffers_size 64k; - } - - location / { - root /var/www/html; - index index.html index.htm; - } -} diff --git a/package.json b/package.json index 457bfa0..c414496 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "app.js", "scripts": { "start": "node app.js", - "dev": "node app.js" + "dev": "node app.js", + "test": "jest --forceExit --detectOpenHandles", + "test:watch": "jest --watch" }, "dependencies": { "@koa/cors": "^4.0.0", @@ -21,5 +23,10 @@ }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "devDependencies": { + "eslint": "^9.0.0", + "jest": "^29.0.0", + "prettier": "^3.5.0" + } } diff --git a/routes/addresses.js b/routes/addresses.js new file mode 100644 index 0000000..f941661 --- /dev/null +++ b/routes/addresses.js @@ -0,0 +1,13 @@ +const Router = require('koa-router') +const addressController = require('../controllers/addresses') + +const router = new Router() + +router.get('/', addressController.getAddresses) +router.get('/:id', addressController.getAddressById) +router.post('/', addressController.createAddress) +router.put('/:id', addressController.updateAddress) +router.delete('/:id', addressController.deleteAddress) +router.put('/:id/default', addressController.setDefault) + +module.exports = router.routes() diff --git a/routes/export.js b/routes/export.js new file mode 100644 index 0000000..c8fb236 --- /dev/null +++ b/routes/export.js @@ -0,0 +1,11 @@ +const Router = require('koa-router') +const exportController = require('../controllers/export') + +const router = new Router() + +router.get('/goods', exportController.exportGoods) +router.get('/orders', exportController.exportOrders) +router.get('/stock', exportController.exportStock) +router.get('/purchases', exportController.exportPurchases) + +module.exports = router.routes() diff --git a/routes/goods-specs.js b/routes/goods-specs.js new file mode 100644 index 0000000..c578824 --- /dev/null +++ b/routes/goods-specs.js @@ -0,0 +1,12 @@ +const Router = require('koa-router') +const specController = require('../controllers/goods-specs') + +const router = new Router() + +router.get('/', specController.getSpecs) +router.post('/', specController.createSpec) +router.put('/:id', specController.updateSpec) +router.delete('/:id', specController.deleteSpec) +router.post('/batch', specController.batchSave) + +module.exports = router.routes() diff --git a/routes/recognize.js b/routes/recognize.js index c8a0ef2..996617f 100644 --- a/routes/recognize.js +++ b/routes/recognize.js @@ -1,87 +1,8 @@ const Router = require('koa-router') -const { query } = require('../config/database') -const fetch = require('node-fetch') -require('dotenv').config() - const router = new Router() -const AI_API_KEY = process.env.DASHSCOPE_API_KEY -const AI_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' +const { getByBarcode, recognizeImage } = require('../controllers/recognize') -router.post('/barcode', async (ctx) => { - try { - const { barcode } = ctx.request.body - if (!barcode) { - ctx.body = { code: 400, message: '请提供条形码' } - return - } - - const goods = await query( - 'SELECT * FROM goods WHERE barcode = ? LIMIT 1', - [barcode] - ) - - if (goods.length > 0) { - ctx.body = { code: 200, data: goods[0] } - } else { - ctx.body = { code: 404, message: '未找到该商品' } - } - } catch (error) { - console.error('Barcode lookup failed:', error) - ctx.body = { code: 500, message: '查询失败' } - } -}) - -router.post('/image', async (ctx) => { - try { - const { imageData } = ctx.request.body - if (!imageData) { - ctx.body = { code: 400, message: '请提供图片数据' } - return - } - - if (!AI_API_KEY) { - ctx.body = { code: 500, message: 'AI 识别未配置' } - return - } - - const response = await fetch(AI_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${AI_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model: 'qwen-vl-max', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: '请识别这张图片中的商品,返回商品名称。只返回名称,不要其他内容。' }, - { type: 'image_url', image_url: { url: imageData } } - ] - } - ] - }) - }) - - const result = await response.json() - const name = result?.choices?.[0]?.message?.content?.trim() || '' - - const goods = name - ? await query('SELECT * FROM goods WHERE name LIKE ? LIMIT 5', [`%${name}%`]) - : [] - - ctx.body = { - code: 200, - data: { - message: name ? `识别到: ${name}` : '未识别到商品', - goods: goods || [] - } - } - } catch (error) { - console.error('Image recognition failed:', error) - ctx.body = { code: 500, message: '识别失败' } - } -}) +router.post('/barcode', getByBarcode) +router.post('/image', recognizeImage) module.exports = router.routes() diff --git a/routes/reports.js b/routes/reports.js new file mode 100644 index 0000000..eab1146 --- /dev/null +++ b/routes/reports.js @@ -0,0 +1,11 @@ +const Router = require('koa-router') +const reportsController = require('../controllers/reports') + +const router = new Router() + +router.get('/sales-trend', reportsController.getSalesTrend) +router.get('/hot-products', reportsController.getHotProducts) +router.get('/profit', reportsController.getProfitAnalysis) +router.get('/inventory-turnover', reportsController.getInventoryTurnover) + +module.exports = router.routes() diff --git a/routes/subscribe.js b/routes/subscribe.js new file mode 100644 index 0000000..08a047a --- /dev/null +++ b/routes/subscribe.js @@ -0,0 +1,8 @@ +const Router = require('koa-router') +const router = new Router() +const { bindOpenId, notifyOrder } = require('../controllers/subscribe') + +router.post('/bind-openid', bindOpenId) +router.post('/orders/notify', notifyOrder) + +module.exports = router.routes() diff --git a/routes/upload.js b/routes/upload.js index ac6e719..69785bc 100644 --- a/routes/upload.js +++ b/routes/upload.js @@ -5,16 +5,19 @@ const fs = require('fs') const router = new Router() -// 确保上传目录存在 -const uploadDir = path.join(__dirname, '..', 'public', 'uploads') -if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) -} +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] +const MAX_SIZE = 5 * 1024 * 1024 + +const uploadDir = path.join(__dirname, '..', 'public', 'uploads') -// 配置 multer const storage = multer.diskStorage({ destination: (req, file, cb) => { - cb(null, uploadDir) + const type = req.query.type || 'goods' + const dir = path.join(uploadDir, type) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + cb(null, dir) }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9) @@ -23,21 +26,27 @@ const storage = multer.diskStorage({ } }) -const upload = multer({ storage }) +const upload = multer({ + storage, + limits: { fileSize: MAX_SIZE }, + fileFilter: (req, file, cb) => { + if (ALLOWED_TYPES.includes(file.mimetype)) { + cb(null, true) + } else { + cb(new Error('不支持的文件类型,仅支持 jpg/png/gif/webp')) + } + } +}) -// 上传接口 router.post('/', upload.single('file'), async (ctx) => { if (!ctx.file) { ctx.status = 400 - ctx.body = { - code: 400, - message: '没有上传文件' - } + ctx.body = { code: 400, message: '没有上传文件' } return } - // 存储相对路径,前端使用时拼接域名 - const fileUrl = `/uploads/${ctx.file.filename}` + const type = ctx.query.type || 'goods' + const fileUrl = `/uploads/${type}/${ctx.file.filename}` ctx.body = { code: 200, message: '上传成功', diff --git a/scripts/convert-to-relative-urls.js b/scripts/convert-to-relative-urls.js deleted file mode 100644 index 0cbba89..0000000 --- a/scripts/convert-to-relative-urls.js +++ /dev/null @@ -1,101 +0,0 @@ -const mysql = require('mysql2/promise'); -const config = require('../config/database'); -const { DOMAIN_CONFIG } = require('../config/domain'); -const { toRelativeUrl } = require('../utils/image-url'); - -async function convertToRelativeUrls() { - try { - const connection = await mysql.createConnection(config); - console.log('✅ 数据库连接成功\n'); - - console.log('🔍 检查数据库中的图片URL...'); - const [goods] = await connection.execute('SELECT id, images FROM goods'); - - console.log(`找到 ${goods.length} 条商品记录\n`); - - let updatedCount = 0; - - // 更新图片URL - for (const item of goods) { - if (item.images) { - let oldImages = item.images; - let newImages = oldImages; - - try { - // 尝试解析JSON - let imagesArray = JSON.parse(oldImages); - - // 转换每个URL - let convertedArray = imagesArray.map(url => toRelativeUrl(url)); - - newImages = JSON.stringify(convertedArray); - - if (oldImages !== newImages) { - await connection.execute( - 'UPDATE goods SET images = ? WHERE id = ?', - [newImages, item.id] - ); - - console.log(`商品ID ${item.id}:`); - console.log(` 旧: ${oldImages}`); - console.log(` 新: ${newImages}`); - console.log('---'); - updatedCount++; - } - } catch (e) { - // 如果不是有效JSON,直接尝试转换 - let converted = toRelativeUrl(oldImages); - if (converted !== oldImages) { - newImages = converted; - await connection.execute( - 'UPDATE goods SET images = ? WHERE id = ?', - [newImages, item.id] - ); - console.log(`商品ID ${item.id} (非JSON格式):`); - console.log(` 旧: ${oldImages}`); - console.log(` 新: ${newImages}`); - console.log('---'); - updatedCount++; - } - } - } - } - - // 检查积分商品 - const [pointsGoods] = await connection.execute('SELECT id, images FROM points_goods'); - if (pointsGoods.length > 0) { - console.log(`\n检查 ${pointsGoods.length} 条积分商品记录...`); - for (const item of pointsGoods) { - if (item.images) { - let oldImages = item.images; - let newImages = oldImages; - - try { - let imagesArray = JSON.parse(oldImages); - let convertedArray = imagesArray.map(url => toRelativeUrl(url)); - newImages = JSON.stringify(convertedArray); - } catch (e) { - newImages = toRelativeUrl(oldImages); - } - - if (oldImages !== newImages) { - await connection.execute( - 'UPDATE points_goods SET images = ? WHERE id = ?', - [newImages, item.id] - ); - console.log(`积分商品ID ${item.id}: 已更新`); - updatedCount++; - } - } - } - } - - await connection.end(); - console.log(`\n✅ 完成!共更新了 ${updatedCount} 条记录`); - } catch (error) { - console.error('❌ 更新失败:', error); - process.exit(1); - } -} - -convertToRelativeUrls(); diff --git a/scripts/fix-image-urls.js b/scripts/fix-image-urls.js deleted file mode 100644 index 355e59f..0000000 --- a/scripts/fix-image-urls.js +++ /dev/null @@ -1,70 +0,0 @@ -const mysql = require('mysql2/promise'); -const config = require('../config/database'); -const { DOMAIN_CONFIG } = require('../config/domain'); - -async function fixImageUrls() { - try { - const connection = await mysql.createConnection(config); - console.log('✅ 数据库连接成功\n'); - - // 获取当前配置的域名 - const targetDomain = DOMAIN_CONFIG.BASE_URL; - console.log(`🎯 目标域名: ${targetDomain}\n`); - - // 查找包含旧地址的记录 - console.log('🔍 检查数据库中的图片URL...'); - const [goods] = await connection.execute('SELECT id, images FROM goods WHERE images LIKE "%localhost%"'); - - console.log(`找到 ${goods.length} 条包含 localhost 的记录\n`); - - // 更新图片URL - for (const item of goods) { - if (item.images) { - const oldImages = item.images; - // 替换所有 localhost:3000 和 localhost:3006 - let newImages = oldImages - .replace(/http:\/\/localhost:3000/g, targetDomain) - .replace(/http:\/\/localhost:3006/g, targetDomain); - - await connection.execute( - 'UPDATE goods SET images = ? WHERE id = ?', - [newImages, item.id] - ); - - console.log(`商品ID ${item.id}:`); - console.log(` 旧: ${oldImages}`); - console.log(` 新: ${newImages}`); - console.log('---'); - } - } - - // 检查其他表(如有需要) - const [pointsGoods] = await connection.execute('SELECT id, images FROM points_goods WHERE images LIKE "%localhost%"'); - if (pointsGoods.length > 0) { - console.log(`\n找到 ${pointsGoods.length} 条积分商品记录...`); - for (const item of pointsGoods) { - if (item.images) { - const oldImages = item.images; - let newImages = oldImages - .replace(/http:\/\/localhost:3000/g, targetDomain) - .replace(/http:\/\/localhost:3006/g, targetDomain); - - await connection.execute( - 'UPDATE points_goods SET images = ? WHERE id = ?', - [newImages, item.id] - ); - - console.log(`积分商品ID ${item.id}: 已更新`); - } - } - } - - await connection.end(); - console.log('\n✅ 所有图片URL更新完成!'); - } catch (error) { - console.error('❌ 更新失败:', error); - process.exit(1); - } -} - -fixImageUrls(); diff --git a/scripts/init-db-root.js b/scripts/init-db-root.js deleted file mode 100644 index 7040723..0000000 --- a/scripts/init-db-root.js +++ /dev/null @@ -1,89 +0,0 @@ -const fs = require('fs') -const path = require('path') -const mysql = require('mysql2/promise') -require('dotenv').config({ path: path.join(__dirname, '../.env') }) - -const categoriesData = require('../data/categories.json') -const goodsData = require('../data/goods.json') -const usersData = require('../data/users.json') - -function requireEnv(name, fallback) { - const value = process.env[name] || fallback - if (!value && !fallback) { - throw new Error(`Missing ${name} in .env`) - } - return value -} - -const config = { - host: requireEnv('DB_HOST'), - port: parseInt(requireEnv('DB_PORT', '3306')), - user: 'root', - password: requireEnv('DB_ROOT_PASSWORD'), - database: requireEnv('DB_NAME', 'miniprogram') -} - -async function run() { - let connection = null - try { - console.log('Connecting to MySQL...') - connection = await mysql.createConnection({ - host: config.host, - port: config.port, - user: config.user, - password: config.password - }) - - console.log('Creating database...') - await connection.query(`CREATE DATABASE IF NOT EXISTS ${config.database} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`) - await connection.query(`USE ${config.database}`) - - console.log('Granting permissions...') - await connection.query('GRANT ALL PRIVILEGES ON miniprogram.* TO admin@"%";') - await connection.query('FLUSH PRIVILEGES;') - - console.log('Creating tables...') - const schema = fs.readFileSync(path.join(__dirname, '../config/schema.sql'), 'utf8') - const statements = schema.split(';').filter(s => s.trim()) - - for (const statement of statements) { - await connection.query(statement) - } - - console.log('Inserting categories...') - for (const cat of categoriesData) { - await connection.query( - 'INSERT INTO categories (id, name, icon, is_show, sort_order) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=VALUES(name), icon=VALUES(icon), is_show=VALUES(is_show), sort_order=VALUES(sort_order)', - [cat.id, cat.name, cat.icon, cat.is_show, cat.sort_order] - ) - } - - console.log('Inserting goods...') - for (const good of goodsData) { - await connection.query( - 'INSERT INTO goods (id, name, price, original_price, unit, category_id, images, stock, sales, is_hot, is_new, pricing_type, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=VALUES(name), price=VALUES(price), original_price=VALUES(original_price)', - [good.id, good.name, good.price, good.original_price, good.unit, good.category_id, good.images, good.stock, good.sales, good.is_hot, good.is_new, good.pricing_type, good.description] - ) - } - - console.log('Inserting users...') - for (const user of usersData) { - await connection.query( - 'INSERT INTO users (id, phone, password, name, avatar, points) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name=VALUES(name), points=VALUES(points)', - [user.id, user.phone, user.password, user.name, user.avatar, user.points] - ) - } - - console.log('Database initialization completed successfully!') - process.exit(0) - } catch (error) { - console.error('Database initialization failed:', error) - process.exit(1) - } finally { - if (connection) { - await connection.end() - } - } -} - -run() \ No newline at end of file diff --git a/scripts/register-donghaiyang.js b/scripts/register-donghaiyang.js deleted file mode 100644 index 78ec2c0..0000000 --- a/scripts/register-donghaiyang.js +++ /dev/null @@ -1,47 +0,0 @@ -const crypto = require('crypto') -const { query } = require('../config/database') - -function md5(str) { - return crypto.createHash('md5').update(str).digest('hex') -} - -async function registerStaff() { - try { - const phone = '12370109282' - const name = '董海洋' - const password = md5('123456') - - const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) - - if (existing.length > 0) { - console.log('⚠️ 该手机号已注册') - - const user = existing[0] - if (user.role === 1) { - console.log('该账号已经是店员身份') - } else { - console.log('该账号是普通用户,正在升级为店员...') - await query('UPDATE users SET role = 1 WHERE phone = ?', [phone]) - console.log('✅ 已升级为店员身份') - } - return - } - - const result = await query( - 'INSERT INTO users (phone, password, name, avatar, points, role, status) VALUES (?, ?, ?, ?, ?, ?, ?)', - [phone, password, name, '', 0, 1, 1] - ) - - console.log('✅ 店员注册成功!') - console.log('姓名: ' + name) - console.log('手机号: ' + phone) - console.log('密码: 123456') - - process.exit(0) - } catch (error) { - console.error('注册失败:', error) - process.exit(1) - } -} - -registerStaff() \ No newline at end of file diff --git a/scripts/register-staff.js b/scripts/register-staff.js deleted file mode 100644 index f0a3a7c..0000000 --- a/scripts/register-staff.js +++ /dev/null @@ -1,50 +0,0 @@ -const crypto = require('crypto') -const { query } = require('../config/database') - -function md5(str) { - return crypto.createHash('md5').update(str).digest('hex') -} - -async function registerStaff() { - try { - const phone = '13070109282' - const name = '店员' // 可以改成你想要的名字 - const password = md5('123456') // 默认密码,需要 md5 加密 - - // 检查是否已存在 - const existing = await query('SELECT * FROM users WHERE phone = ?', [phone]) - - if (existing.length > 0) { - console.log('⚠️ 该手机号已注册') - - // 如果已存在,检查是否是店员 - const user = existing[0] - if (user.role === 1) { - console.log('该账号已经是店员身份,无需重复注册') - } else { - console.log('该账号是普通用户,正在升级为店员...') - await query('UPDATE users SET role = 1 WHERE phone = ?', [phone]) - console.log('✅ 已升级为店员身份') - } - return - } - - // 插入店员账号 - const result = await query( - 'INSERT INTO users (phone, password, name, avatar, points, role, status) VALUES (?, ?, ?, ?, ?, ?, ?)', - [phone, password, name, '', 0, 1, 1] // role=1 表示店员 - ) - - console.log('✅ 店员注册成功!') - console.log('手机号: ' + phone) - console.log('姓名: ' + name) - console.log('默认密码: 123456') - - process.exit(0) - } catch (error) { - console.error('注册失败:', error) - process.exit(1) - } -} - -registerStaff() diff --git a/scripts/test-db.js b/scripts/test-db.js deleted file mode 100644 index c670e70..0000000 --- a/scripts/test-db.js +++ /dev/null @@ -1,36 +0,0 @@ -const mysql = require('mysql2/promise'); -const path = require('path'); -require('dotenv').config({ path: path.join(__dirname, '../.env') }); - -const config = { - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '3306'), - user: process.env.DB_USER || 'root', - password: process.env.DB_PASSWORD || '', - database: process.env.DB_NAME || 'miniprogram' -}; - -async function testConnection() { - try { - console.log('尝试连接到:', config.host, config.port); - const connection = await mysql.createConnection(config); - console.log('✅ 数据库连接成功\n'); - - // 检查所有商品的图片URL - const [goods] = await connection.execute('SELECT id, name, images FROM goods LIMIT 10'); - console.log(`找到 ${goods.length} 条商品记录\n`); - - for (const item of goods) { - console.log(`商品ID ${item.id}: ${item.name}`); - console.log(` 图片: ${item.images}`); - } - - await connection.end(); - console.log('\n✅ 检查完成'); - } catch (error) { - console.error('❌ 连接失败:', error.message); - process.exit(1); - } -} - -testConnection(); diff --git a/services/orderService.js b/services/orderService.js new file mode 100644 index 0000000..f207b3a --- /dev/null +++ b/services/orderService.js @@ -0,0 +1,112 @@ +const { query, transaction } = require('../config/database') +const { POINTS_RATE } = require('../config/constants') + +function parseCart(cart) { + if (typeof cart === 'string') { + try { return JSON.parse(cart) } catch { return [] } + } + return Array.isArray(cart) ? cart : [] +} + +async function insertOrderItems(conn, orderId, cart) { + const items = parseCart(cart) + if (items.length === 0) return + for (const item of items) { + const qty = item.pricingType === 2 ? 1 : (item.quantity || 1) + const price = parseFloat(item.price || 0) + const subtotal = item.subtotal !== undefined ? parseFloat(item.subtotal) : price * qty + await conn.execute( + 'INSERT INTO order_items (order_id, goods_id, goods_name, price, quantity, weight, subtotal, unit) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [orderId, item.id || item.goods_id, item.name || item.goods_name, price, qty, item.weight || null, subtotal, item.unit || ''] + ) + } +} + +async function getOrderItems(orderId) { + return query('SELECT * FROM order_items WHERE order_id = ?', [orderId]) +} + +async function attachOrderItems(orders) { + if (!Array.isArray(orders)) orders = [orders] + const ids = orders.map(o => o.id) + if (ids.length === 0) return orders + const rows = await query('SELECT * FROM order_items WHERE order_id IN (' + ids.map(() => '?').join(',') + ')', ids) + const grouped = {} + for (const row of rows) { + if (!grouped[row.order_id]) grouped[row.order_id] = [] + grouped[row.order_id].push(row) + } + for (const o of orders) { + o.items = grouped[o.id] || [] + } + return orders +} + +async function updateStockForOrder(order) { + const cart = typeof order.cart === 'string' ? JSON.parse(order.cart) : order.cart + if (!Array.isArray(cart) || cart.length === 0) return + + let items = cart + if (order.items && order.items.length > 0) { + items = order.items + } + + await transaction(async (conn) => { + for (const item of items) { + const goodsId = item.id || item.goods_id || item.goodsId + if (!goodsId) continue + const qty = item.pricingType === 2 ? 1 : (item.quantity || 1) + await conn.execute('UPDATE goods SET stock = GREATEST(0, stock - ?), sales = COALESCE(sales, 0) + ? WHERE id = ?', [qty, qty, goodsId]) + await conn.execute('UPDATE stock SET quantity = GREATEST(0, quantity - ?) WHERE goods_id = ?', [qty, goodsId]) + } + }) +} + +async function addPoints(userId, orderId, totalPrice) { + if (!userId) return + const points = Math.floor(parseFloat(totalPrice) * POINTS_RATE) + if (points <= 0) return + + await transaction(async (conn) => { + const userRows = await conn.execute('SELECT points FROM users WHERE id = ? AND status = 1', [userId]) + if (userRows[0].length > 0) { + const newPoints = (userRows[0][0].points || 0) + points + await conn.execute('UPDATE users SET points = ? WHERE id = ?', [newPoints, userId]) + await conn.execute( + 'INSERT INTO points_logs (user_id, type, amount, description) VALUES (?, ?, ?, ?)', + [userId, 'earn', points, `订单 ${orderId} 消费自动累计`] + ) + } + }) +} + +async function sendWechatNotification(userId, orderId, status, totalPrice) { + try { + const rows = await query('SELECT openid FROM users WHERE id = ?', [userId]) + const openid = rows.length > 0 ? rows[0].openid : undefined + if (openid) { + const { sendOrderStatusNotification } = require('../utils/wechat') + await sendOrderStatusNotification(openid, orderId, status, totalPrice).catch(err => { console.error('发送微信通知失败:', err) }) + } + } catch (err) { console.error('发送微信通知失败:', err) } +} + +async function processOrderComplete(order) { + try { + await updateStockForOrder(order) + await addPoints(order.user_id, order.id, order.total_price) + await sendWechatNotification(order.user_id, order.id, 'completed', order.total_price) + } catch (err) { + console.error('订单完成自动联动失败:', err) + } +} + +module.exports = { + parseCart, + insertOrderItems, + getOrderItems, + attachOrderItems, + updateStockForOrder, + addPoints, + processOrderComplete +} diff --git a/utils/pagination.js b/utils/pagination.js new file mode 100644 index 0000000..deb56f7 --- /dev/null +++ b/utils/pagination.js @@ -0,0 +1,20 @@ +async function paginate(queryFn, sql, params, page = 1, pageSize = 20) { + const p = Math.max(1, parseInt(page) || 1) + const ps = Math.min(100, Math.max(1, parseInt(pageSize) || 20)) + + const countResult = await queryFn( + `SELECT COUNT(*) as total FROM (${sql}) AS _paged`, + params + ) + const total = countResult[0].total + const totalPages = Math.ceil(total / ps) + + const data = await queryFn( + `${sql} LIMIT ? OFFSET ?`, + [...params, ps, (p - 1) * ps] + ) + + return { data, total, page: p, pageSize: ps, totalPages } +} + +module.exports = { paginate } diff --git a/utils/wechat.js b/utils/wechat.js new file mode 100644 index 0000000..ddae6b9 --- /dev/null +++ b/utils/wechat.js @@ -0,0 +1,87 @@ +const fetch = require('node-fetch') + +const APPID = process.env.WECHAT_APPID +const SECRET = process.env.WECHAT_SECRET + +let accessToken = null +let tokenExpiresAt = 0 + +async function getAccessToken() { + if (!APPID || !SECRET) { + throw new Error('WECHAT_APPID and WECHAT_SECRET must be configured in .env') + } + + if (accessToken && Date.now() < tokenExpiresAt) { + return accessToken + } + + const res = await fetch( + `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${SECRET}` + ) + const data = await res.json() + + if (data.errcode) { + throw new Error(`微信API错误: ${data.errmsg}`) + } + + accessToken = data.access_token + tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000 + return accessToken +} + +async function sendSubscribeMessage(openid, templateId, data, page = '') { + const token = await getAccessToken() + + const body = { + touser: openid, + template_id: templateId, + page, + data, + miniprogram_state: 'formal' + } + + const res = await fetch( + `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + } + ) + const result = await res.json() + + if (result.errcode && result.errcode !== 0) { + console.error('发送订阅消息失败:', result) + return { success: false, error: result.errmsg } + } + + return { success: true } +} + +async function sendOrderStatusNotification(openid, orderId, status, totalPrice) { + const statusText = { + paid: '已付款', + completed: '已完成', + cancelled: '已取消' + } + + const templateId = process.env.WECHAT_ORDER_TEMPLATE_ID + if (!templateId) { + console.warn('WECHAT_ORDER_TEMPLATE_ID not configured, skipping notification') + return { success: false, error: 'template not configured' } + } + + return sendSubscribeMessage(openid, templateId, { + thing1: { value: `订单 ${orderId}` }, + phrase2: { value: statusText[status] || status }, + amount3: { value: `¥${totalPrice}` }, + date4: { value: new Date().toLocaleString('zh-CN') }, + thing5: { value: '点击查看订单详情' } + }, `/pages/customer/order-detail/order-detail?id=${orderId}`) +} + +module.exports = { + getAccessToken, + sendSubscribeMessage, + sendOrderStatusNotification +}