尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PHP积分兑换商城系统源码:账本一致、幂等下单与独立后台实现

PHP积分兑换商城系统源码:账本一致、幂等下单与独立后台实现 简介一套基于PHP的积分兑换商城系统源码附带独立代理后台与详细搭建教程面向需要快速搭建积分商城、电商平台的开发者、站长及PHP初学者解决从环境配置到上线部署的完整流程问题。包内共2000个文件包含678个html、341个css、318个php、237个js及231个dat等类型其中html/css/js构成前端页面与交互php文件承载商城核心业务逻辑dat与配置类文件提供数据支撑和系统设置压缩包整体292.91MB目录结构清晰。商城支持Linux Centos7、宝塔面板、Nginx 1.18.0、PHP7.0与Mysql5.6环境伪静态采用ThinkPHP规则数据库配置路径明确压缩包内附详细搭建教程从环境准备到后台部署均有说明可快速完成独立代理后台部署。同时包含支付接口证书、加密密钥等关键文件适合学习ThinkPHP开发、商城系统架构、支付集成及二次开发。目前已有714人学习下载。1. php积分兑换商城系统源码难在账本一致性和独立后台积分兑换商城系统源码这类项目仓库里最不缺的是前台页面和商品列表真正决定能不能上线的是积分账本、订单一致性和独立后台。这套系统的做法是把积分当钱记账所有变动留流水兑换走事务后台与用户端分开登录、分开会话、分开权限附带的搭建教程覆盖 Nginx、PHP-FPM、MySQL 的完整落地步骤。适合刚做完几个 CRUD 就想完整接手积分项目的人也适合带队评审商城模块的工程师——照着账本和事务部分的设计能避开绝大多数上线后的脏数据问题。2. 独立后台从积分账本设计开始把积分当钱记账2.1 为什么不能在用户表里直接改 points 字段很多团队的 v1 就是这么写的UPDATE user SET points points - 100 WHERE uid ?。单机低并发下它能跑但积分一旦可兑换商品审计要求就变了订单退款时要知道当时扣了多少活动补发时要知道这次变动前的余额运营对账时要能回答“某个区间发了多少、消耗多少”。只维护一个余额字段以上全部没有依据。常见做法是拆成账户表和流水表账户表只保存当前余额流水表记录每一次变动以及变动后的余额。查询余额走账户表对账走流水表两者靠事务保证一致。账户表可以理解为余额缓存流水表才是事实源。2.2 四张核心表的结构与索引写法积分商城的最小闭合需要四张表管理员表、商品表、积分账户表、流水表再加一张订单表来处理兑换。下面是可直接导入 MySQL 的建表语句MySQL 5.7/8.0InnoDButf8mb4CREATE TABLE admin_user ( id int unsigned NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL, password varchar(255) NOT NULL COMMENT password_hash 结果, role_id int unsigned NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT独立后台管理员; CREATE TABLE points_goods ( id bigint unsigned NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL, cover varchar(255) NOT NULL DEFAULT , cost_points int NOT NULL COMMENT 兑换所需积分, stock int NOT NULL DEFAULT 0, audit_status tinyint NOT NULL DEFAULT 0 COMMENT 0待审核 1通过 2驳回, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 2下架, PRIMARY KEY (id), KEY idx_audit_status (audit_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分商品;订单表、账户表和流水表是另一个紧密相关的集合唯一键的写法直接决定后面幂等能不能成立CREATE TABLE points_account ( id bigint unsigned NOT NULL AUTO_INCREMENT, user_id bigint unsigned NOT NULL, balance int NOT NULL DEFAULT 0 COMMENT 可用积分, frozen int NOT NULL DEFAULT 0 COMMENT 冻结中, version int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分账户; CREATE TABLE points_flow ( id bigint unsigned NOT NULL AUTO_INCREMENT, user_id bigint unsigned NOT NULL, biz_type varchar(32) NOT NULL COMMENT redeem/refund/grant/adjust, biz_id varchar(64) NOT NULL COMMENT 业务单号, change_value int NOT NULL COMMENT 正为加负为减, balance_after int NOT NULL COMMENT 变动后可用积分, remark varchar(255) NOT NULL DEFAULT , created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id), KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水; CREATE TABLE points_order ( id bigint unsigned NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, request_id varchar(64) NOT NULL COMMENT 前端幂等键, user_id bigint unsigned NOT NULL, goods_id bigint unsigned NOT NULL, cost_points int NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待确认 1已兑换 2已取消, deliver_info varchar(255) DEFAULT NULL COMMENT 兑换码或卡密, created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_request_id (request_id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT兑换订单;这几张表的字段都不多重点看三个索引约定表唯一键作用points_accountuk_user_id一个用户只有一行账户防止重复开户points_flowuk_biz_type_biz_id同一业务单号只记一次账幂等兜底points_orderuk_request_id同一请求键只生成一单防重复下单2.3 积分字段的三个硬约定第一积分一律用 int 存最小单位不要在 PHP 和 MySQL 之间来回传 float。0.1 加 0.2 的浮点误差在报表里会被放大成对不上的账。第二流水表的 change_value 带正负号balance_after 冗余记录变动后余额对账时不把账户表查进来也能算出区间净变动。第三账户表加 version 字段做乐观锁配合兑换接口里的SELECT ... FOR UPDATE使用后台人工调整走乐观锁用户兑换走行锁两种场景互不干扰。2.3.1 后台调整积分时用乐观锁的写法$newBalance $account[balance] $delta; $affected $db-prepare( UPDATE points_account SET balance ?, version version 1 WHERE user_id ? AND version ? )-execute([$newBalance, $userId, $account[version]]); if ($affected 0) { // version 不匹配说明这一行被并发改过必须重读重试 }这里把验证交给数据库的 version 比较比先查后改多一层保护。后台批量补发、人工纠错这类低并发操作用乐观锁足够用户多设备同时操作时 version 不匹配直接返回“操作过于频繁请重试”避免静默覆盖别人的余额。3. 兑换下单核心流程冻结、扣减与幂等3.1 兑换状态机与“先冻结后确认”的选择兑换实物或虚拟卡密订单至少要经历待确认、已兑换、已取消三个阶段。为什么要有“待确认”因为实物要出库出库失败的订单如果已经扣了积分用户侧就会变成“积分没了货没到”。常见做法是下单即扣减但把积分先冻结锁住后台发货成功后再把冻结转成实扣失败则解冻退回。状态流转如下状态值名称积分状态触发动作0待确认冻结中兑换成功生成订单1已兑换已实扣后台确认发货2已取消已解冻用户取消或超时这套系统的兑换接口一次完成“扣库存 冻结积分 写流水 建订单”四步在一个事务里任何一步失败整体回滚。3.2 用事务和行锁把四个步骤放进同一事务PHP 侧用 PDO 长事务重点是先锁用户账户行再锁商品行顺序固定避免两个并发请求互相持锁形成死锁public function redeem(PDO $db, int $userId, int $goodsId, string $requestId): array { $db-beginTransaction(); try { // 幂等检查同一 requestId 直接返回已有订单 $stmt $db-prepare( SELECT id, order_no FROM points_order WHERE request_id ? FOR UPDATE ); $stmt-execute([$requestId]); if ($row $stmt-fetch()) { $db-commit(); return [ok true, order_no $row[order_no], duplicated true]; } // 锁用户账户行防止两个请求同时读到同一余额 $stmt $db-prepare( SELECT balance FROM points_account WHERE user_id ? FOR UPDATE ); $stmt-execute([$userId]); $account $stmt-fetch(); if (!$account) { throw new RuntimeException(账户不存在); } // 锁商品行并读取价格 $stmt $db-prepare( SELECT cost_points, stock FROM points_goods WHERE id ? AND status 1 AND audit_status 1 FOR UPDATE ); $stmt-execute([$goodsId]); $goods $stmt-fetch(); if (!$goods || $goods[stock] 0) { throw new RuntimeException(商品已下架或缺货); } if ($account[balance] $goods[cost_points]) { throw new RuntimeException(积分不足); } // 条件更新扣库存stock 0 是最后一道防线 $stmt $db-prepare( UPDATE points_goods SET stock stock - 1 WHERE id ? AND status 1 AND stock 0 ); $stmt-execute([$goodsId]); if ($stmt-rowCount() ! 1) { throw new RuntimeException(库存不足); } // 冻结积分并写流水 $db-prepare( UPDATE points_account SET frozen frozen ?, version version 1 WHERE user_id ? )-execute([$goods[cost_points], $userId]); $orderNo R . date(YmdHis) . mt_rand(100000, 999999); $db-prepare( INSERT INTO points_order (order_no, request_id, user_id, goods_id, cost_points, status) VALUES (?, ?, ?, ?, ?, 0) )-execute([$orderNo, $requestId, $userId, $goodsId, $goods[cost_points]]); $db-commit(); return [ok true, order_no $orderNo]; } catch (Throwable $e) { $db-rollBack(); return [ok false, error $e-getMessage()]; } }参数逐个说明$requestId是前端生成的唯一请求标识每次点击兑换时用uniqid()或 UUID 生成一次失败重试时带同一个值FOR UPDATE让两个并发请求在账户行上排队第二个请求必须等第一个提交后才会读到新余额扣库存的条件更新不依赖读取后的判断由数据库保证stock 0才减减成功才rowCount() 1。把这些串起来扣库存和积分操作之间不存在先减谁后减谁的中间状态。3.3 幂等键为什么必须落到唯一索引上兑换请求在移动端经常被用户连点也可能被网关自动重试服务端不做幂等一次点击就是一笔重复扣减。这套系统把 request_id 建唯一索引事务内先按它查订单查到就返回旧订单不再新建即使两个请求同时到达第二个请求的 INSERT 会撞唯一索引抛异常回滚后返回失败也不会产生两张单。3.3.1 幂等判断放在事务内的原因如果把幂等检查放在事务外两个请求可能同时通过“未查到”再同时进事务唯一索引能兜住插入冲突但已经执行完的扣积分可能来不及回滚。放在事务内配合FOR UPDATE锁住 request_id 对应的索引区间第二个请求在查询阶段就阻塞等第一个提交后才能读逻辑最简单也不用额外写补偿。3.4 冻结转实扣与解冻的补偿路径后台确认发货后把冻结积分转成实扣并写一条真正意义的消耗流水$db-beginTransaction(); // 锁定订单行防止发货确认被并发重复执行 $stmt $db-prepare( SELECT id, status, cost_points, user_id FROM points_order WHERE order_no ? FOR UPDATE ); $stmt-execute([$orderNo]); $order $stmt-fetch(); if ($order[status] ! 0) { throw new RuntimeException(订单状态不可确认); } // 冻结转实扣frozen 减balance 减 $db-prepare( UPDATE points_account SET frozen frozen - ?, balance balance - ?, version version 1 WHERE user_id ? )-execute([$order[cost_points], $order[cost_points], $order[user_id]]); // 写一条实扣流水biz_id 用订单号 $db-prepare( INSERT INTO points_flow (user_id, biz_type, biz_id, change_value, balance_after) VALUES (?, ?, ?, ?, ?) )-execute([$order[user_id], redeem, $orderNo, -$order[cost_points], $newBalance]); $db-prepare(UPDATE points_order SET status 1 WHERE order_no ?)-execute([$orderNo]); $db-commit();用户取消订单时反过来frozen 减回去balance 不变流水记一条refund正向变动biz_id 仍用原订单号。因为流水唯一键是biz_type biz_id兑换和退款互不覆盖同一种类型却不会记两遍。如果商品是卡密类常见做法是下单后把 order_no 塞进 Redis 队列再用 CLI 写的消费者 worker 负责回写 deliver_info这套消费者逻辑就属于 php 队列的典型应用。队列消费失败时订单停留在“待确认”由定时任务扫 30 分钟以上未完成的单子做人工介入兑换接口不被第三方发货接口卡住。4. 独立后台的具体实现单独入口、独立会话与权限校验4.1 独立后台和用户端隔离在哪三层标题里的“独立后台”不是换主题色而是三个层面的隔离。第一层是入口隔离后台固定放在单独目录或子域名下不混进用户端路由入口路径只在部署配置里写。第二层是会话隔离后台 session_name 与用户端不同Cookie 互不读取登录态不会串号。第三层是权限隔离后台管理员走独立的 admin_user 表不继承用户表的角色商品审核、积分调整这些写操作额外做权限点校验。注意后台 session_name 一旦上线就不要改改动后所有已登录管理员的会话全部失效运营会以为系统被人重置了。4.2 后台登录接口与密码存储的写法管理员密码一定用 password_hash 存 bcrypt登录用 password_verify 校验不要自己发明 md5 加盐// api/admin/login.php $username trim($_POST[username] ?? ); $password $_POST[password] ?? ; $stmt $db-prepare(SELECT * FROM admin_user WHERE username ? AND status 1); $stmt-execute([$username]); $admin $stmt-fetch(); if (!$admin || !password_verify($password, $admin[password])) { // 这里要记录失败次数连续 5 次锁定该用户名 15 分钟 exit(json_encode([code 401, msg 用户名或密码错误])); } session_name(ADMIN_SESS); session_start(); session_regenerate_id(true); // 登录成功后换新会话 ID防会话固定 $_SESSION[admin] [ id $admin[id], username $admin[username], role_id $admin[role_id], ];登录成功后调用session_regenerate_id是经常被漏掉的一步不换 ID攻击者已经拿到的旧 Session ID 依然有效。独立后台的会话名要和用户端分开设否则同一浏览器下用户端登录会覆盖后台登录态后台频繁被踢下线。4.3 后台写操作接口的权限中间件后台所有写操作接口前面统一加权限检查前置文件src/admin_auth.php被所有后台接口 require// src/admin_auth.php session_name(ADMIN_SESS); session_start(); $admin $_SESSION[admin] ?? null; if (!$admin) { http_response_code(401); exit(json_encode([code 401, msg 请先登录])); } function require_permission(string $perm): void { $perms $_SESSION[admin][perms] ?? []; if (!in_array($perm, $perms, true)) { http_response_code(403); exit(json_encode([code 403, msg 无操作权限])); } }权限点放会话里还是每次查库低并发后台可以直接把权限点数组挂在$_SESSION上登录时一次性读出管理员改权限后要求重新登录生效。权限粒度按模块.操作命名比单一的超管/普通管理员两档好扩展权限点对应操作说明goods.audit商品审核通过或驳回运营提交的商品points.adjust积分调整人工补发、误扣追回order.deliver订单发货确认出库、回写卡密4.4 商品审核接口与 php 图片上传处理商品由运营提交后走审核审核接口是 goods.audit 权限点的典型场景require __DIR__ . /../../src/admin_auth.php; require_permission(goods.audit); $goodsId (int)($_POST[goods_id] ?? 0); $auditStatus ($_POST[action] ?? ) pass ? 1 : 2; $stmt $db-prepare( UPDATE points_goods SET audit_status ?, audited_at NOW(), auditor_id ? WHERE id ? ); $stmt-execute([$auditStatus, $_SESSION[admin][id], $goodsId]); if ($stmt-rowCount() 0) { exit(json_encode([code 400, msg 商品不存在或状态未变化])); } exit(json_encode([code 0, msg ok]));失败时区分“不存在”和“状态未变化”rowCount 为 0 不一定是商品不存在也可能是审核状态没变接口里把这两个分支分开记日志否则运营会拿着截图问你“明明点通过了没反应”。商品图上传属于 php 图片生成场景。一张 2MB 的 JPG 直接塞服务器后台列表页会卡常见做法是校验真实 MIME 后重新编码并生成缩略图$file $_FILES[cover]; $finfo finfo_open(FILEINFO_MIME_TYPE); $mime finfo_file($finfo, $file[tmp_name]); if (!in_array($mime, [image/jpeg, image/png, image/webp], true)) { exit(json_encode([code 400, msg 只允许 jpg/png/webp])); } if ($file[size] 2 * 1024 * 1024) { exit(json_encode([code 400, msg 图片不能超过 2MB])); } // GD 重新编码并等比缩放到 800x800原图丢弃 $src imagecreatefromstring(file_get_contents($file[tmp_name])); $thumb imagescale($src, 800, 800); imagejpeg($thumb, /var/www/mall/public/uploads/goods/ . uniqid() . .jpg, 85);用 GD 而不是直接 move_uploaded_file是为了在服务端重新编码一次去掉 Exif 里的地理位置等多余信息同时把尺寸压下来。imagescale 等比缩放不会像强行 imagecopyresized 那样把图拉变形。5. Nginx PHP-FPM 下的搭建部署步骤5.1 搭建教程对应的环境清单这套源码按传统 PHP 栈编写不依赖 Docker最小环境如下组件版本建议说明PHP7.4 - 8.2需要 pdo_mysql、redis、gd、fileinfo 扩展MySQL5.7 / 8.0InnoDButf8mb4Nginx1.18站点入口处理 PHP 转发Redis5.x兑换卡密队列用不用可省Supervisor可选守护队列消费者进程5.2 从源码包部署到可访问的完整命令拿到源码压缩包后按下面顺序操作每步的目地在命令后面说明# 1. 解压到部署目录public 是 Web 根目录 sudo mkdir -p /var/www/mall sudo unzip points_mall_v1.zip -d /var/www/mall # 2. 目录权限PHP-FPM 以 www-data 运行需要写日志和上传目录 sudo chown -R www-data:www-data /var/www/mall sudo chmod -R 755 /var/www/mall sudo chmod -R 775 /var/www/mall/runtime /var/www/mall/public/uploads # 3. 复制配置模板并编辑数据库连接 cd /var/www/mall cp config.example.php config.php sudo -u www-data vi config.php # 4. 建库并导入 SQL mysql -uroot -p -e CREATE DATABASE points_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; mysql -uroot -p points_mall database/points_mall.sql第 2 步的权限最容易出错。用 root 解压后 runtime 目录归 root 所有php-fpm 的 worker 写日志和 session 时会报 Permission denied表现是页面白屏但 Nginx 日志正常。统一 chown 给 www-data 再 chmod比单独给某个文件加 777 安全得多。config.php 里除了数据库账号还要配admin_path和session_name两个值部署后不要再改改了后台所有登录态会失效。5.3 Nginx 站点配置与 PHP 参数调整Nginx 部分的重点是把动态请求交给 php-fpm同时拦截敏感文件server { listen 80; server_name mall.example.com; root /var/www/mall/public; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php8.1-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~* \.(sql|env|md|log|bak)$ { deny all; } location ~* /uploads/.*\.php$ { return 403; } }/uploads/.*\.php$这段覆盖上传目录防止有人把普通图片改成带 PHP 代码的文件传上去后被直接执行。PHP 侧要调几个参数参数推荐值作用upload_max_filesize8M商品图上传上限超过即报错post_max_size16M必须比 upload_max_filesize 大date.timezoneAsia/Shanghai不设会出现时间差 8 小时opcache.enable1PHP 8 默认开启生产不要关改完 php.ini 要重启 php-fpmsudo systemctl reload php8.1-fpm php -i | grep opcache.enable5.4 上线前必须检查的配置项部署完成先别急着开放注册。检查 config.php 的 debug 开关要关错误显示设为 off否则 SQL 报错会把表结构直接打给前端确认后台路径不是默认的 admin改成只有自己知道的目录名MySQL 不要用 root 连接应用单独建 points_app 账号并只授权 points_mall 库。这四项是搭建教程里最常被跳过的部分缺一项都够上线后折腾一晚上。6. 并发兑换验证与 php 队列守护的实战技巧6.1 用 20 个并发请求验证只成功一单上线前最值得做的一个验证把某商品库存设为 1积分充足然后并发打兑换接口正确结果是恰好一个订单成功其余全部返回缺货或库存不足且积分只扣一次。用 shell 写一个最简单的并发模拟for i in $(seq 1 20); do curl -s -X POST http://127.0.0.1/api/redeem \ -H Content-Type: application/json \ -d {\goods_id\:1,\user_id\:1001,\request_id\:\bench-$i\} done wait跑完查订单表mysql -upoints_app -p points_mall \ -e SELECT status, COUNT(*) FROM points_order WHERE request_id LIKE bench-% GROUP BY status;如果 status1 的行数大于 1说明事务或唯一索引没生效如果 status0 的行数大于 1说明重复请求带了不同幂等键对账时要能解释来源。这个验证跑通兑换核心逻辑就有底了。6.2 队列消费者用 Supervisor 守护卡密发货的 php 队列消费者用 Supervisor 守护崩溃自动拉起日志统一收集[program:redeem_worker] commandphp /var/www/mall/bin/consumer.php process_name%(program_name)s_%(process_num)02d numprocs2 autostarttrue autorestarttrue userwww-data redirect_stderrtrue stdout_logfile/var/www/mall/runtime/redeem_worker.log之后用supervisorctl status确认 RUNNING。消费者进程数超过 1 时消费逻辑里必须对订单行做锁或状态前置判断否则两个 worker 可能同时给同一张单回写卡密。6.3 三个最隐晦的坑第一个是 SQL 严格模式。MySQL 8 默认 sql_mode 带 STRICT_TRANS_TABLES往cost_points这种 NOT NULL int 字段插入带空格的字符串会直接报错表现是“订单创建失败但库存已扣”。扣减前把所有入参做强转(int)一次不亏。第二个是时区php.ini 和 MySQL 的 time_zone 不一致时订单时间与流水时间对不上运营按天对账时少一条多一条都说不清两边统一 Asia/Shanghai。第三个是冻结积分变成负数解冻逻辑里没判断 frozen 大小就减脏数据会让账户余额变负MySQL 8 可以在账户表加CHECK (frozen 0)兜底5.7 就在解冻 SQL 里写死frozen ?条件rowCount 为 0 时按异常处理。这三个坑不解决压测再好看也不敢接真实流量。本文还有配套的精品资源点击获取
返回列表