1304 lines
39 KiB
Plaintext
1304 lines
39 KiB
Plaintext
|
||
--[[
|
||
webstats_common.lua - WebStats 统计系统核心模块
|
||
==================================================
|
||
|
||
本模块提供 WebStats 系统的核心功能,包括:
|
||
- 数据库连接管理
|
||
- 日志数据处理与存储
|
||
- 定时任务调度
|
||
- 爬虫/客户端识别
|
||
- IP/URL 统计
|
||
|
||
架构说明:
|
||
1. 单例模式:通过 getInstance() 获取全局唯一实例
|
||
2. 共享内存:使用 ngx.shared.mw_total 作为日志缓存队列
|
||
3. SQLite 数据库:每个站点独立一个数据库文件
|
||
4. 定时任务:每 0.5 秒执行一次日志持久化
|
||
|
||
主要数据流向:
|
||
webstats_log.lua (日志采集) -> ngx.shared.mw_total (缓存队列) -> cron() (定时处理) -> SQLite (持久化)
|
||
|
||
依赖模块:
|
||
- cjson: JSON 编解码
|
||
- lsqlite3: SQLite 数据库操作
|
||
- webstats_config: 全局配置
|
||
- webstats_sites: 站点配置
|
||
]]
|
||
|
||
local setmetatable = setmetatable
|
||
local _M = { _VERSION = '0.2.5' }
|
||
local mt = { __index = _M }
|
||
|
||
-- 依赖模块
|
||
local json = require "cjson"
|
||
local sqlite3 = require "lsqlite3"
|
||
local config = require "webstats_config"
|
||
local sites = require "webstats_sites"
|
||
|
||
-- 调试模式开关
|
||
local debug_mode = true
|
||
-- 共享内存队列键名
|
||
local total_key = "log_kv_total"
|
||
|
||
-- 未匹配到站点时使用的默认名称
|
||
local unset_server_name = "unset"
|
||
-- 日志 ID 最大值,超过后重置
|
||
local max_log_id = 99999999999999
|
||
-- Nginx 共享内存字典实例
|
||
local cache = ngx.shared.mw_total
|
||
|
||
-- 当前日期(日)
|
||
local day = os.date("%d")
|
||
local number_day = tonumber(day)
|
||
-- 数据库列名:day + 日期(如 day01)
|
||
local day_column = "day" .. number_day
|
||
-- 数据库列名:flow + 日期(如 flow01)
|
||
local flow_column = "flow" .. number_day
|
||
|
||
-- 当前站点的配置(模块级变量,供多个函数共享)
|
||
local auto_config = nil
|
||
|
||
-- 当前日期(用于更新日志文件)
|
||
local today = ngx.re.gsub(ngx.today(), '-', '')
|
||
|
||
--[[
|
||
创建模块实例
|
||
@return table 模块实例对象
|
||
]]
|
||
function _M.new(self)
|
||
local self = {
|
||
app_dir = "", ---
|
||
total_key = total_key, -- 共享内存队列键名
|
||
params = nil, -- 请求参数
|
||
site_config = nil, -- 站点配置
|
||
config = nil, -- 全局配置
|
||
}
|
||
return setmetatable(self, mt)
|
||
end
|
||
|
||
|
||
--[[
|
||
获取单例实例(懒加载)
|
||
首次调用时创建实例并启动定时任务
|
||
@return table 全局唯一的模块实例
|
||
]]
|
||
function _M.getInstance(self)
|
||
if self.instance == nil then
|
||
self.instance = self:new()
|
||
end
|
||
assert(self.instance ~= nil)
|
||
return self.instance
|
||
end
|
||
|
||
function _M.start_cron(self)
|
||
if ngx.worker.id() ~= 0 then
|
||
return
|
||
end
|
||
self:cron()
|
||
end
|
||
|
||
|
||
function _M.setAppDir(self, dir)
|
||
self.app_dir = dir
|
||
end
|
||
|
||
|
||
--[[
|
||
初始化 SQLite 数据库连接
|
||
@param input_sn string 站点名称
|
||
@return table|nil SQLite 数据库对象,失败返回 nil
|
||
|
||
SQLite 优化配置:
|
||
- synchronous = 0: 关闭同步写入,提升性能(可能丢失数据)
|
||
- cache_size = 8000: 设置页缓存大小为 8000 页
|
||
- page_size = 32768: 设置页大小为 32KB
|
||
- journal_mode = wal: 使用 WAL 模式,支持并发读写
|
||
- journal_size_limit = 20GB: WAL 文件大小限制
|
||
]]
|
||
function _M.initDB(self, input_sn)
|
||
log_dir = self.app_dir .. "/logs"
|
||
if log_dir == "" then
|
||
return nil
|
||
end
|
||
|
||
local path = log_dir .. '/' .. input_sn .. "/logs.db"
|
||
local db, err = sqlite3.open(path)
|
||
|
||
if err then
|
||
return nil
|
||
end
|
||
|
||
db:exec([[PRAGMA synchronous = 0]])
|
||
db:exec([[PRAGMA cache_size = 8000]])
|
||
db:exec([[PRAGMA page_size = 32768]])
|
||
db:exec([[PRAGMA journal_mode = wal]])
|
||
db:exec([[PRAGMA journal_size_limit = 21474836480]])
|
||
db:exec([[PRAGMA busy_timeout = 5000]])
|
||
return db
|
||
end
|
||
|
||
--[[
|
||
获取共享内存队列键名
|
||
@return string 队列键名
|
||
]]
|
||
function _M.getTotalKey(self)
|
||
return self.total_key
|
||
end
|
||
|
||
--[[
|
||
JSON 编码
|
||
@param msg table 待编码的数据
|
||
@return string JSON 字符串
|
||
]]
|
||
function _M.to_json(self, msg)
|
||
return json.encode(msg)
|
||
end
|
||
|
||
--[[
|
||
设置配置数据
|
||
@param config table 全局配置
|
||
@param site_config table 站点配置
|
||
]]
|
||
function _M.setConfData(self, config, site_config)
|
||
self.config = config
|
||
self.site_config = site_config
|
||
end
|
||
|
||
--[[
|
||
设置请求参数
|
||
@param params table 请求参数
|
||
]]
|
||
function _M.setParams(self, params)
|
||
self.params = params
|
||
end
|
||
|
||
--[[
|
||
设置输入站点名称并获取合并后的配置
|
||
将站点配置与全局配置合并,站点配置优先级更高
|
||
@param input_sn string 站点名称
|
||
@return table 合并后的配置
|
||
]]
|
||
function _M.setInputSn(self, input_sn)
|
||
local global_config = config["global"]
|
||
if config[input_sn] == nil then
|
||
auto_config = global_config
|
||
else
|
||
auto_config = config[input_sn]
|
||
-- 合并全局配置到站点配置(站点配置优先)
|
||
for k, v in pairs(global_config) do
|
||
if auto_config[k] == nil then
|
||
auto_config[k] = v
|
||
end
|
||
end
|
||
end
|
||
return auto_config
|
||
end
|
||
|
||
--[[
|
||
获取当前请求的域名
|
||
@return string 域名,失败返回 "unknown"
|
||
]]
|
||
function _M.get_domain(self)
|
||
local domain = ngx.var.host
|
||
if domain == nil then
|
||
domain = "unknown"
|
||
end
|
||
return domain
|
||
end
|
||
|
||
--[[
|
||
字符串分割函数
|
||
@param str string|table 待分割的字符串或已分割的表
|
||
@param reps string 分隔符
|
||
@return table 分割后的数组
|
||
|
||
注意:如果输入已经是 table 类型,直接返回原表(用于处理反向代理传递的数据)
|
||
]]
|
||
function _M.split(self, str, reps)
|
||
if "table" == type(str) then
|
||
return str
|
||
end
|
||
local arr = {}
|
||
local pattern = "[^" .. reps .. "]+"
|
||
for w in string.gmatch(str, pattern) do
|
||
arr[#arr + 1] = w
|
||
end
|
||
return arr
|
||
end
|
||
|
||
--[[
|
||
获取数组长度
|
||
@param arr table 数组
|
||
@return number 数组长度,空或 nil 返回 0
|
||
]]
|
||
function _M.arrlen(self, arr)
|
||
if not arr then return 0 end
|
||
return #arr
|
||
end
|
||
|
||
--[[
|
||
验证 IPv4 地址格式
|
||
@param client_ip string IP 地址
|
||
@return boolean 是否为有效的 IPv4 地址
|
||
]]
|
||
function _M.is_ipaddr(self, client_ip)
|
||
if not client_ip then return false end
|
||
local parts = self:split(client_ip, '.')
|
||
if #parts ~= 4 then return false end
|
||
for i = 1, 4 do
|
||
local ipv = tonumber(parts[i])
|
||
if not ipv or ipv < 0 or ipv > 255 then return false end
|
||
end
|
||
return true
|
||
end
|
||
|
||
|
||
--[[
|
||
根据域名/服务器名获取站点名称(带缓存)
|
||
匹配规则:
|
||
1. 精确匹配站点名称
|
||
2. 精确匹配站点域名列表
|
||
3. 通配符匹配(支持 * 通配符)
|
||
@param input_sn string 输入的域名或服务器名
|
||
@return string 匹配到的站点名称,未匹配返回 "unset"
|
||
|
||
缓存策略:匹配结果缓存 24 小时(86400 秒)
|
||
]]
|
||
function _M.get_sn(self, input_sn)
|
||
-- 优先从缓存获取
|
||
local dst_name = cache:get(input_sn)
|
||
if dst_name then return dst_name end
|
||
|
||
-- 遍历所有站点进行匹配
|
||
for _, v in ipairs(sites) do
|
||
-- 精确匹配站点名称
|
||
if input_sn == v["name"] then
|
||
cache:set(input_sn, v['name'], 86400)
|
||
return v["name"]
|
||
end
|
||
|
||
-- 遍历站点的域名列表
|
||
for _, dst_domain in ipairs(v['domains']) do
|
||
-- 精确匹配域名
|
||
if input_sn == dst_domain then
|
||
cache:set(input_sn, v['name'], 86400)
|
||
return v['name']
|
||
-- 通配符匹配(如 *.example.com)
|
||
elseif string.find(dst_domain, "*", 1, true) then
|
||
local pattern = "^" .. string.gsub(dst_domain, "*", ".*") .. "$"
|
||
if ngx.re.match(input_sn, pattern, "ijo") then
|
||
cache:set(input_sn, v['name'], 86400)
|
||
return v['name']
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 未匹配到任何站点
|
||
cache:set(input_sn, unset_server_name, 86400)
|
||
return unset_server_name
|
||
end
|
||
|
||
|
||
--[[
|
||
获取存储键(按小时分组)
|
||
格式:YYYYMMDDHH(如 2026070213)
|
||
@return string 存储键
|
||
]]
|
||
function _M.get_store_key(self)
|
||
return os.date("%Y%m%d%H", ngx.time())
|
||
end
|
||
|
||
--[[
|
||
根据指定时间获取存储键
|
||
@param htime number 时间戳
|
||
@return string 存储键
|
||
]]
|
||
function _M.get_store_key_with_time(self, htime)
|
||
return os.date("%Y%m%d%H", htime)
|
||
end
|
||
|
||
--[[
|
||
获取响应体大小
|
||
@return number 响应体字节数
|
||
]]
|
||
function _M.get_length(self)
|
||
local clen = ngx.var.body_bytes_sent
|
||
if clen == nil then clen = 0 end
|
||
return tonumber(clen)
|
||
end
|
||
|
||
--[[
|
||
获取日志唯一 ID(自动递增)
|
||
使用共享内存实现跨 Worker 进程的 ID 递增
|
||
@param input_sn string 站点名称
|
||
@return number 日志 ID
|
||
|
||
溢出处理:ID 达到 max_log_id 后重置为 1
|
||
]]
|
||
function _M.get_last_id(self, input_sn)
|
||
local last_insert_id_key = input_sn .. "_last_id"
|
||
-- 原子递增,初始值为 0
|
||
local new_id, err = cache:incr(last_insert_id_key, 1, 0)
|
||
-- 溢出检查
|
||
if new_id >= max_log_id then
|
||
cache:set(last_insert_id_key, 1)
|
||
new_id = cache:get(last_insert_id_key)
|
||
end
|
||
return new_id
|
||
end
|
||
|
||
--[[
|
||
获取 HTTP 请求原始数据(请求头 + 请求体)
|
||
仅对非 GET 请求记录请求体
|
||
@return string JSON 格式的请求数据
|
||
]]
|
||
function _M.get_http_origin(self)
|
||
local data = ""
|
||
local headers = {
|
||
user_agent = ngx.var.http_user_agent,
|
||
referer = ngx.var.http_referer,
|
||
host = ngx.var.host,
|
||
x_forwarded_for = ngx.var.http_x_forwarded_for
|
||
}
|
||
|
||
local req_method = ngx.var.request_method
|
||
if req_method ~= 'GET' then
|
||
data = ngx.var.request_body
|
||
if data and "string" == type(data) then
|
||
headers["payload"] = data
|
||
end
|
||
end
|
||
return json.encode(headers)
|
||
end
|
||
|
||
--[[
|
||
定时任务前置准备(初始化统计记录)
|
||
为当前小时和下一小时预创建统计记录,避免 UPDATE 时记录不存在
|
||
|
||
处理流程:
|
||
1. 获取锁防止并发执行
|
||
2. 遍历所有站点
|
||
3. 为每个站点初始化数据库连接
|
||
4. 在事务中预创建统计记录(request_stat, client_stat, spider_stat)
|
||
5. 提交事务并关闭数据库
|
||
|
||
@return boolean 是否成功
|
||
]]
|
||
function _M.cronPre(self)
|
||
self:lock_working('cron_init_stat')
|
||
|
||
local ok, err = pcall(function()
|
||
-- 获取当前小时和下一小时的存储键
|
||
local time_key = self:get_store_key()
|
||
local time_key_next = self:get_store_key_with_time(ngx.time() + 3600)
|
||
|
||
-- 需要预创建的统计表
|
||
local wc_stat = {'request_stat', 'client_stat', 'spider_stat'}
|
||
|
||
-- 遍历所有站点
|
||
for _, site_v in ipairs(sites) do
|
||
local input_sn = site_v["name"]
|
||
|
||
-- 安全地初始化数据库连接
|
||
local db_ok, db = pcall(function() return self:initDB(input_sn) end)
|
||
if not db_ok or not db then
|
||
self:D("cronPre initDB failed for " .. tostring(input_sn) .. ": " .. tostring(db))
|
||
return false
|
||
end
|
||
|
||
-- 开启事务
|
||
db:exec([[BEGIN TRANSACTION]])
|
||
|
||
local success = true
|
||
-- 预创建当前小时和下一小时的统计记录
|
||
for _, ws_v in ipairs(wc_stat) do
|
||
if not self:_update_stat_pre(db, ws_v, time_key) then
|
||
success = false
|
||
break
|
||
end
|
||
if not self:_update_stat_pre(db, ws_v, time_key_next) then
|
||
success = false
|
||
break
|
||
end
|
||
end
|
||
|
||
-- 提交或回滚事务
|
||
if success then
|
||
pcall(function() db:execute([[COMMIT]]) end)
|
||
else
|
||
pcall(function() db:exec([[ROLLBACK]]) end)
|
||
end
|
||
|
||
-- 安全关闭数据库
|
||
pcall(function() db:close() end)
|
||
|
||
if not success then
|
||
return false
|
||
end
|
||
end
|
||
|
||
return true
|
||
end)
|
||
|
||
self:unlock_working('cron_init_stat')
|
||
|
||
if not ok then
|
||
self:D("cronPre error: " .. tostring(err))
|
||
return false
|
||
end
|
||
|
||
return err
|
||
end
|
||
|
||
--[[
|
||
定时任务调度器
|
||
启动一个每 0.5 秒执行一次的定时器,负责:
|
||
1. 从共享内存队列中读取日志数据
|
||
2. 将日志写入 SQLite 数据库
|
||
3. 统计请求、客户端、爬虫数据
|
||
4. 更新 IP 和 URI 统计
|
||
5. 清理过期日志
|
||
|
||
执行流程:
|
||
1. 检查队列是否有数据
|
||
2. 获取锁防止并发执行
|
||
3. 初始化所有站点的数据库连接
|
||
4. 批量处理队列中的日志数据
|
||
5. 更新统计信息
|
||
6. 清理过期日志
|
||
7. 提交事务并关闭连接
|
||
]]
|
||
function _M.cron(self)
|
||
--[[
|
||
定时任务核心处理函数
|
||
@param premature boolean 是否为定时器提前触发
|
||
]]
|
||
local timer_every_get_data = function (premature)
|
||
|
||
local llen = ngx.shared.mw_total:llen(total_key)
|
||
if llen == 0 then
|
||
return true
|
||
end
|
||
|
||
local cron_key = 'cron_every_1s'
|
||
if self:is_working(cron_key) then
|
||
return true
|
||
end
|
||
|
||
local ready_ok = self:cronPre()
|
||
if not ready_ok then
|
||
return true
|
||
end
|
||
|
||
self:lock_working(cron_key)
|
||
|
||
local dbs = {}
|
||
local stmts = {}
|
||
local stat_fields = {}
|
||
local ip_stats = {}
|
||
local url_stats = {}
|
||
local rollback_sites = {}
|
||
|
||
local time_key = self:get_store_key()
|
||
|
||
local function cleanup_and_unlock()
|
||
for _, site_v in ipairs(sites) do
|
||
local input_sn = site_v["name"]
|
||
local stmt = stmts[input_sn]
|
||
if stmt then
|
||
pcall(function() stmt:finalize() end)
|
||
end
|
||
local db = dbs[input_sn]
|
||
if db and db:isopen() then
|
||
pcall(function() db:close() end)
|
||
end
|
||
end
|
||
self:unlock_working(cron_key)
|
||
end
|
||
|
||
local ok, err = pcall(function()
|
||
for _, site_v in ipairs(sites) do
|
||
local input_sn = site_v["name"]
|
||
if self:is_migrating(input_sn) then
|
||
return
|
||
end
|
||
|
||
if self:is_working('cron_init_stat') then
|
||
return
|
||
end
|
||
|
||
local db_ok, db = pcall(function() return self:initDB(input_sn) end)
|
||
if not db_ok or not db then
|
||
self:D("initDB failed for " .. input_sn .. ": " .. tostring(db))
|
||
return
|
||
end
|
||
|
||
stat_fields[input_sn] = {}
|
||
|
||
dbs[input_sn] = db
|
||
self:clean_stats(db, input_sn)
|
||
|
||
local stmt_ok, stmt = pcall(function()
|
||
return db:prepare[[INSERT INTO web_logs(
|
||
time, ip, domain, server_name, method, status_code, uri, body_length,
|
||
referer, user_agent, protocol, request_time, is_spider, request_headers, ip_list, client_port)
|
||
VALUES(:time, :ip, :domain, :server_name, :method, :status_code, :uri,
|
||
:body_length, :referer, :user_agent, :protocol, :request_time, :is_spider,
|
||
:request_headers, :ip_list, :client_port)]]
|
||
end)
|
||
|
||
if stmt_ok and stmt then
|
||
stmts[input_sn] = stmt
|
||
db:exec([[BEGIN TRANSACTION]])
|
||
else
|
||
if db and db:isopen() then
|
||
db:close()
|
||
end
|
||
dbs[input_sn] = nil
|
||
return
|
||
end
|
||
end
|
||
|
||
for i = 1, llen do
|
||
local data = ngx.shared.mw_total:lpop(total_key)
|
||
if not data then
|
||
break
|
||
end
|
||
|
||
local decode_ok, info = pcall(function() return json.decode(data) end)
|
||
if not decode_ok then
|
||
self:D("json decode failed: " .. tostring(info))
|
||
ngx.shared.mw_total:rpush(total_key, data)
|
||
return
|
||
end
|
||
|
||
local input_sn = info['server_name']
|
||
local db = dbs[input_sn]
|
||
if not db then
|
||
ngx.shared.mw_total:rpush(total_key, data)
|
||
return
|
||
end
|
||
|
||
local stmt = stmts[input_sn]
|
||
if not stmt then
|
||
ngx.shared.mw_total:rpush(total_key, data)
|
||
return
|
||
end
|
||
|
||
local insert_ok = self:store_logs_line(db, stmt, input_sn, info)
|
||
if not insert_ok then
|
||
ngx.shared.mw_total:rpush(total_key, data)
|
||
rollback_sites[input_sn] = true
|
||
return
|
||
end
|
||
|
||
local log_kv = info["log_kv"]
|
||
local excluded = log_kv['excluded']
|
||
local stat_tmp_fields = info['stat_fields']
|
||
|
||
local stat_fields_is = stat_fields[input_sn]
|
||
for stf_k, stf_v in pairs(stat_tmp_fields) do
|
||
if excluded then
|
||
if stf_k == "spider_stat_fields" or stf_k == "client_stat_fields" then
|
||
break
|
||
end
|
||
end
|
||
|
||
local stf_is = stat_fields_is[stf_k]
|
||
if not stf_is then
|
||
stf_is = {}
|
||
stat_fields_is[stf_k] = stf_is
|
||
end
|
||
|
||
for sv_k, sv_v in pairs(stf_v) do
|
||
stf_is[sv_k] = (stf_is[sv_k] or 0) + sv_v
|
||
end
|
||
end
|
||
|
||
if not excluded then
|
||
local ip = log_kv['ip']
|
||
local body_length = log_kv["body_length"]
|
||
|
||
local ip_stats_sn = ip_stats[input_sn]
|
||
if not ip_stats_sn then
|
||
ip_stats_sn = {}
|
||
ip_stats[input_sn] = ip_stats_sn
|
||
end
|
||
|
||
local ip_stat = ip_stats_sn[ip]
|
||
if not ip_stat then
|
||
ip_stats_sn[ip] = {ip_num = 1, body_length = body_length}
|
||
else
|
||
ip_stat.ip_num = ip_stat.ip_num + 1
|
||
ip_stat.body_length = ip_stat.body_length + body_length
|
||
end
|
||
|
||
local url_stats_sn = url_stats[input_sn]
|
||
if not url_stats_sn then
|
||
url_stats_sn = {}
|
||
url_stats[input_sn] = url_stats_sn
|
||
end
|
||
|
||
local request_uri = log_kv["request_uri"]
|
||
local request_uri_md5 = ngx.md5(request_uri)
|
||
local url_stat = url_stats_sn[request_uri_md5]
|
||
if not url_stat then
|
||
url_stats_sn[request_uri_md5] = {url_num = 1, uri = request_uri, body_length = body_length}
|
||
else
|
||
url_stat.url_num = url_stat.url_num + 1
|
||
url_stat.body_length = url_stat.body_length + body_length
|
||
end
|
||
end
|
||
end
|
||
|
||
local save_day = config['global']["save_day"]
|
||
local now_date = os.date("*t")
|
||
local save_date_timestamp = os.time{year = now_date.year, month = now_date.month, day = now_date.day - save_day, hour = 0}
|
||
local delete_sql = "DELETE FROM web_logs WHERE time<" .. tostring(save_date_timestamp)
|
||
|
||
for _, site_v in ipairs(sites) do
|
||
local input_sn = site_v["name"]
|
||
|
||
local stmt = stmts[input_sn]
|
||
if stmt then
|
||
pcall(function() stmt:finalize() end)
|
||
end
|
||
|
||
local stat_fields_is = stat_fields[input_sn]
|
||
local db = dbs[input_sn]
|
||
|
||
if db then
|
||
if rollback_sites[input_sn] then
|
||
pcall(function() db:exec([[ROLLBACK]]) end)
|
||
else
|
||
for sti_k, sti_v in pairs(stat_fields_is) do
|
||
local vkk = ""
|
||
for sv_k, sv_v in pairs(sti_v) do
|
||
vkk = vkk .. sv_k .. "=" .. sv_k .. "+" .. sv_v .. ","
|
||
end
|
||
if vkk ~= "" then
|
||
vkk = string.sub(vkk, 1, string.len(vkk) - 1)
|
||
|
||
if sti_k == 'request_stat_fields' then
|
||
self:update_stat(db, "request_stat", time_key, vkk)
|
||
elseif sti_k == 'client_stat_fields' then
|
||
self:update_stat(db, "client_stat", time_key, vkk)
|
||
elseif sti_k == 'spider_stat_fields' then
|
||
self:update_stat(db, "spider_stat", time_key, vkk)
|
||
end
|
||
end
|
||
end
|
||
|
||
local local_ip_stats = ip_stats[input_sn]
|
||
if local_ip_stats then
|
||
for ip_addr, ip_val in pairs(local_ip_stats) do
|
||
self:update_statistics_ip(db, ip_addr, ip_val.ip_num, ip_val.body_length)
|
||
end
|
||
end
|
||
|
||
local local_url_stats = url_stats[input_sn]
|
||
if local_url_stats then
|
||
for url_md5, url_val in pairs(local_url_stats) do
|
||
self:update_statistics_uri(db, url_val.uri, url_md5, url_val.url_num, url_val.body_length)
|
||
end
|
||
end
|
||
|
||
db:exec(delete_sql)
|
||
|
||
pcall(function() db:execute([[COMMIT]]) end)
|
||
end
|
||
end
|
||
|
||
if db and db:isopen() then
|
||
pcall(function() db:close() end)
|
||
end
|
||
end
|
||
end)
|
||
|
||
if not ok then
|
||
self:D("cron process error: " .. tostring(err))
|
||
cleanup_and_unlock()
|
||
return true
|
||
end
|
||
|
||
self:unlock_working(cron_key)
|
||
end
|
||
|
||
|
||
function timer_every_get_data_try()
|
||
local presult, err = pcall(function() timer_every_get_data() end)
|
||
if not presult then
|
||
self:D("debug cron error on :" .. tostring(err))
|
||
return true
|
||
end
|
||
end
|
||
|
||
function timer_every_get_data_try_debug()
|
||
ngx.update_time()
|
||
local start_time = ngx.now()
|
||
local start_llen = ngx.shared.mw_total:llen(total_key)
|
||
|
||
local presult, err = pcall(function() timer_every_get_data() end)
|
||
|
||
ngx.update_time()
|
||
local end_time = ngx.now()
|
||
local end_llen = ngx.shared.mw_total:llen(total_key)
|
||
local duration = (end_time - start_time) * 1000
|
||
local processed = start_llen - end_llen
|
||
|
||
if not presult then
|
||
self:D("debug cron error on :" .. tostring(err))
|
||
return true
|
||
end
|
||
|
||
if start_llen > 0 then
|
||
self:D(string.format("cron process: took %.2fms, start=%d, end=%d, processed=%d",
|
||
duration, start_llen, end_llen, processed))
|
||
end
|
||
end
|
||
|
||
ngx.timer.every(0.5, timer_every_get_data_try)
|
||
end
|
||
|
||
|
||
function _M.store_logs_line(self, db, stmt, input_sn, info)
|
||
local logline = info['log_kv']
|
||
|
||
local excluded = logline["excluded"]
|
||
if excluded then
|
||
return true
|
||
end
|
||
|
||
local user_agent = logline["user_agent"]
|
||
if "table" == type(user_agent) then
|
||
user_agent = self:to_json(user_agent)
|
||
end
|
||
|
||
stmt:bind_names {
|
||
time = logline["time"],
|
||
ip = logline["ip"],
|
||
domain = logline["domain"],
|
||
server_name = logline["server_name"],
|
||
method = logline["method"],
|
||
status_code = logline["status_code"],
|
||
uri = logline["request_uri"],
|
||
body_length = logline["body_length"],
|
||
referer = logline["referer"],
|
||
user_agent = user_agent,
|
||
protocol = logline["protocol"],
|
||
request_time = logline["request_time"],
|
||
is_spider = logline["is_spider"],
|
||
request_headers = logline["request_headers"],
|
||
ip_list = logline["ip_list"],
|
||
client_port = logline["client_port"],
|
||
}
|
||
|
||
local res = stmt:step()
|
||
if tostring(res) == "5" then
|
||
stmt:reset()
|
||
return false
|
||
end
|
||
stmt:reset()
|
||
return true
|
||
end
|
||
|
||
function _M.statistics_ipc(self, input_sn, ip)
|
||
-- 判断IP是否重复的时间限定范围是请求的当前时间+24小时
|
||
local ipc = 0
|
||
local ip_token = input_sn..'_'..ip
|
||
if not cache:get(ip_token) then
|
||
ipc = 1
|
||
cache:set(ip_token,1, self:get_end_time())
|
||
end
|
||
return ipc
|
||
end
|
||
|
||
function _M.statistics_request(self, ip, is_spider, body_length)
|
||
local pvc = 0
|
||
local uvc = 0
|
||
if not is_spider and ngx.status == 200 and body_length > 0 then
|
||
local ua = ngx.var.http_user_agent or ''
|
||
ua = string.lower(ua)
|
||
|
||
pvc = 1
|
||
if ua and ua ~= '' then
|
||
local today = ngx.today()
|
||
local uv_token = ngx.md5(ip .. ua .. today)
|
||
if not cache:get(uv_token) then
|
||
uvc = 1
|
||
cache:set(uv_token,1, self:get_end_time())
|
||
end
|
||
end
|
||
end
|
||
return pvc, uvc
|
||
end
|
||
|
||
function _M.statistics_request_old(self, ip, is_spider, body_length)
|
||
local pvc = 0
|
||
local uvc = 0
|
||
local method = ngx.var.request_method
|
||
if not is_spider and method == 'GET' and ngx.status == 200 and body_length > 512 then
|
||
local ua = ngx.var.http_user_agent or ''
|
||
ua = string.lower(ua)
|
||
|
||
local content_type = ngx.var.content_type
|
||
if content_type and string.find(content_type, 'text/html', 1, true) then
|
||
pvc = 1
|
||
if ua and string.find(ua,'mozilla') then
|
||
local today = ngx.today()
|
||
local uv_token = ngx.md5(ip .. ua .. today)
|
||
if not cache:get(uv_token) then
|
||
uvc = 1
|
||
cache:set(uv_token,1, self:get_end_time())
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return pvc, uvc
|
||
end
|
||
|
||
--------------------- db start ---------------------------
|
||
|
||
|
||
function _M.update_statistics_uri(self, db, uri, uri_md5, day_num, body_length)
|
||
local open_statistics_uri = config['global']["statistics_uri"]
|
||
if not open_statistics_uri then return true end
|
||
|
||
local insert_stmt = db:prepare("INSERT INTO uri_stat(uri_md5, uri) SELECT :uri_md5, :uri WHERE NOT EXISTS (SELECT uri_md5 FROM uri_stat WHERE uri_md5 = :uri_md5);")
|
||
insert_stmt:bind_names{uri_md5 = uri_md5, uri = uri}
|
||
insert_stmt:step()
|
||
insert_stmt:finalize()
|
||
|
||
local update_sql = "UPDATE uri_stat SET " .. day_column .. "=" .. day_column .. "+" .. day_num .. ", " .. flow_column .. "=" .. flow_column .. "+" .. body_length .. " WHERE uri_md5=\"" .. uri_md5 .. "\";"
|
||
db:exec(update_sql)
|
||
|
||
return true
|
||
end
|
||
|
||
function _M.statistics_uri(self, db, uri, uri_md5, body_length)
|
||
-- count the number of URI requests and traffic
|
||
local open_statistics_uri = config['global']["statistics_uri"]
|
||
if not open_statistics_uri then return true end
|
||
|
||
local stat_sql = nil
|
||
stat_sql = "INSERT INTO uri_stat(uri_md5,uri) SELECT \""..uri_md5.."\",\""..uri.."\" WHERE NOT EXISTS (SELECT uri_md5 FROM uri_stat WHERE uri_md5=\""..uri_md5.."\");"
|
||
local res, err = db:exec(stat_sql)
|
||
|
||
stat_sql = "UPDATE uri_stat SET "..day_column.."="..day_column.."+1,"..flow_column.."="..flow_column.."+"..body_length.." WHERE uri_md5=\""..uri_md5.."\";"
|
||
local res, err = db:exec(stat_sql)
|
||
return true
|
||
end
|
||
|
||
|
||
function _M.update_statistics_ip(self, db, ip, day_num, body_length)
|
||
local open_statistics_ip = config['global']["statistics_ip"]
|
||
if not open_statistics_ip then return true end
|
||
|
||
local insert_stmt = db:prepare("INSERT INTO ip_stat(ip) SELECT :ip WHERE NOT EXISTS (SELECT ip FROM ip_stat WHERE ip = :ip);")
|
||
insert_stmt:bind_names{ip = ip}
|
||
insert_stmt:step()
|
||
insert_stmt:finalize()
|
||
|
||
local update_sql = "UPDATE ip_stat SET " .. day_column .. "=" .. day_column .. "+" .. day_num .. ", " .. flow_column .. "=" .. flow_column .. "+" .. body_length .. " WHERE ip=\"" .. ip .. "\";"
|
||
db:exec(update_sql)
|
||
|
||
return true
|
||
end
|
||
|
||
function _M.statistics_ip(self, db, ip, body_length)
|
||
return self:update_statistics_ip(db, ip, 1, body_length)
|
||
end
|
||
|
||
|
||
function _M._update_stat_pre(self, db, stat_table, key)
|
||
local local_sql = string.format("INSERT INTO %s(time) SELECT :time WHERE NOT EXISTS(SELECT time FROM %s WHERE time=:time);", stat_table, stat_table)
|
||
local update_stat_stmt = db:prepare(local_sql)
|
||
|
||
if update_stat_stmt then
|
||
update_stat_stmt:bind_names{time=key}
|
||
update_stat_stmt:step()
|
||
update_stat_stmt:finalize()
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
function _M.update_stat_quick(self, db, stat_table, key,columns)
|
||
if not columns then return end
|
||
local update_sql = "UPDATE ".. stat_table .. " SET " .. columns .. " WHERE time=" .. key
|
||
return db:exec(update_sql)
|
||
end
|
||
|
||
|
||
function _M.update_stat(self, db, stat_table, key, columns)
|
||
if not columns then return end
|
||
|
||
local insert_stmt = db:prepare("INSERT INTO " .. stat_table .. "(time) SELECT :time WHERE NOT EXISTS(SELECT time FROM " .. stat_table .. " WHERE time=:time);")
|
||
insert_stmt:bind_names{time = key}
|
||
insert_stmt:step()
|
||
insert_stmt:finalize()
|
||
|
||
local update_sql = "UPDATE " .. stat_table .. " SET " .. columns .. " WHERE time=" .. key
|
||
return db:exec(update_sql)
|
||
end
|
||
--------------------- db end ---------------------------
|
||
|
||
function _M.D(self,msg)
|
||
if not debug_mode then return true end
|
||
local fp = io.open(self.app_dir..'/debug.log', 'ab')
|
||
if fp == nil then
|
||
return nil
|
||
end
|
||
local localtime = os.date("%Y-%m-%d %H:%M:%S")
|
||
fp:write(localtime..":"..tostring(msg) .. "\n")
|
||
fp:flush()
|
||
fp:close()
|
||
return true
|
||
end
|
||
|
||
function _M.is_migrating(self, input_sn)
|
||
local file = io.open(self.app_dir.."/migrating", "rb")
|
||
if file then return true end
|
||
local file = io.open(self.app_dir.."/logs/"..input_sn.."/migrating", "rb")
|
||
if file then return true end
|
||
return false
|
||
end
|
||
|
||
function _M.is_working(self,sign)
|
||
local work_status = cache:get(sign.."_working")
|
||
if work_status ~= nil and work_status == true then
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
|
||
function _M.lock_working(self, sign)
|
||
local working_key = sign.."_working"
|
||
cache:set(working_key, true, 10)
|
||
end
|
||
|
||
function _M.unlock_working(self, sign)
|
||
local working_key = sign.."_working"
|
||
cache:set(working_key, false)
|
||
end
|
||
|
||
function _M.write_file(self, filename, body, mode)
|
||
local fp = io.open(filename, mode)
|
||
if fp == nil then
|
||
return nil
|
||
end
|
||
fp:write(body)
|
||
fp:flush()
|
||
fp:close()
|
||
return true
|
||
end
|
||
|
||
function _M.read_file_body(self, filename)
|
||
local fp = io.open(filename,'rb')
|
||
if not fp then
|
||
return nil
|
||
end
|
||
local fbody = fp:read("*a")
|
||
fp:close()
|
||
if fbody == '' then
|
||
return nil
|
||
end
|
||
return fbody
|
||
end
|
||
|
||
function _M.load_update_day(self, input_sn)
|
||
local _file = self.app_dir.."/logs/"..input_sn.."/update_day.log"
|
||
return self:read_file_body(_file)
|
||
end
|
||
|
||
function _M.write_update_day(self, input_sn)
|
||
local _file = self.app_dir.."/logs/"..input_sn.."/update_day.log"
|
||
return self:write_file(_file, today, "w")
|
||
end
|
||
|
||
function _M.clean_stats(self, db, input_sn)
|
||
-- 清空 uri,ip 汇总的信息[昨日]
|
||
local update_day = self:load_update_day(input_sn)
|
||
if not update_day or update_day ~= today then
|
||
|
||
local update_sql = "UPDATE uri_stat SET "..day_column.."=0,"..flow_column.."=0"
|
||
db:exec(update_sql)
|
||
|
||
update_sql = "UPDATE ip_stat SET "..day_column.."=0,"..flow_column.."=0"
|
||
db:exec(update_sql)
|
||
self:write_update_day(input_sn)
|
||
end
|
||
end
|
||
|
||
function _M.lpop(self)
|
||
local cache = ngx.shared.mw_total
|
||
return cache:lpop(total_key)
|
||
end
|
||
|
||
function _M.rpop(self)
|
||
local cache = ngx.shared.mw_total
|
||
return cache:rpop(total_key)
|
||
end
|
||
|
||
|
||
function _M.get_update_field(self, field, value)
|
||
return field.."="..field.."+"..tostring(value)
|
||
end
|
||
|
||
function _M.get_request_time(self)
|
||
local request_time = math.floor(tonumber(ngx.var.request_time or 0) * 1000)
|
||
if request_time == 0 then request_time = 1 end
|
||
return request_time
|
||
end
|
||
|
||
|
||
function _M.get_end_time(self)
|
||
local s_time = ngx.time()
|
||
local n_date = os.date("*t",s_time + 86400)
|
||
n_date.hour = 0
|
||
n_date.min = 0
|
||
n_date.sec = 0
|
||
local d_time = ngx.time(n_date)
|
||
return d_time - s_time
|
||
end
|
||
|
||
|
||
local spider_table = {
|
||
["baidu"] = 1,
|
||
["bing"] = 2,
|
||
["qh360"] = 3,
|
||
["google"] = 4,
|
||
["bytes"] = 5,
|
||
["sogou"] = 6,
|
||
["youdao"] = 7,
|
||
["soso"] = 8,
|
||
["dnspod"] = 9,
|
||
["yandex"] = 10,
|
||
["yisou"] = 11,
|
||
["other"] = 12,
|
||
["mpcrawler"] = 13,
|
||
["yahoo"] = 14,
|
||
["duckduckgo"] = 15
|
||
}
|
||
|
||
local spider_keywords = {
|
||
["baidu"] = "baidu",
|
||
["bytes"] = "bytes",
|
||
["360spider"] = "qh360",
|
||
["sogou"] = "sogou",
|
||
["soso"] = "soso",
|
||
["google"] = "google",
|
||
["bingbot"] = "bing",
|
||
["youdao"] = "youdao",
|
||
["dnspod"] = "dnspod",
|
||
["yandex"] = "yandex",
|
||
["yisou"] = "yisou",
|
||
["mpcrawler"] = "mpcrawler",
|
||
["yahoo"] = "yahoo",
|
||
["slurp"] = "other",
|
||
["duckduckgo"] = "other",
|
||
["semrush"] = "other",
|
||
["spider"] = "other",
|
||
["bot"] = "other",
|
||
["crawler"] = "other"
|
||
}
|
||
|
||
function _M.match_spider(self, ua)
|
||
local is_spider = false
|
||
local spider_name = ""
|
||
|
||
if not ua then
|
||
return false, "", 0
|
||
end
|
||
|
||
local lower_ua = string.lower(ua)
|
||
|
||
for kw, name in pairs(spider_keywords) do
|
||
if string.find(lower_ua, kw, 1, true) then
|
||
is_spider = true
|
||
spider_name = name
|
||
break
|
||
end
|
||
end
|
||
|
||
if is_spider then
|
||
return is_spider, spider_name, spider_table[spider_name]
|
||
end
|
||
|
||
return false, "", 0
|
||
end
|
||
|
||
|
||
local clients_map = {
|
||
["android"] = "android",
|
||
["iphone"] = "iphone",
|
||
["ipod"] = "iphone",
|
||
["ipad"] = "iphone",
|
||
["firefox"] = "firefox",
|
||
["msie"] = "msie",
|
||
["trident"] = "msie",
|
||
["360se"] = "qh360",
|
||
["360ee"] = "qh360",
|
||
["360browser"] = "qh360",
|
||
["qihoo"] = "qh360",
|
||
["theworld"] = "theworld",
|
||
["tencenttraveler"] = "tt",
|
||
["maxthon"] = "maxthon",
|
||
["opera"] = "opera",
|
||
["qqbrowser"] = "qq",
|
||
["ucweb"] = "uc",
|
||
["ubrowser"] = "uc",
|
||
["safari"] = "safari",
|
||
["chrome"] = "chrome",
|
||
["metasr"] = "metasr",
|
||
["2345explorer"] = "pc2345",
|
||
["edge"] = "edg",
|
||
["edg"] = "edg",
|
||
["windows"] = "windows",
|
||
["linux"] = "linux",
|
||
["macintosh"] = "mac",
|
||
["mobile"] = "mobile"
|
||
}
|
||
|
||
local mobile_keywords = {"mobile", "android", "iphone", "ipod", "ipad"}
|
||
local pc_browser_keywords = {
|
||
["360se"] = "qh360",
|
||
["360ee"] = "qh360",
|
||
["360browser"] = "qh360",
|
||
["qihoo"] = "qh360",
|
||
["theworld"] = "theworld",
|
||
["tencenttraveler"] = "tt",
|
||
["maxthon"] = "maxthon",
|
||
["opera"] = "opera",
|
||
["qqbrowser"] = "qq",
|
||
["ucweb"] = "uc",
|
||
["ubrowser"] = "uc",
|
||
["metasr"] = "metasr",
|
||
["2345explorer"] = "pc2345",
|
||
["edge"] = "edg",
|
||
["firefox"] = "firefox",
|
||
["chrome"] = "chrome",
|
||
["safari"] = "safari",
|
||
["msie"] = "msie",
|
||
["trident"] = "msie"
|
||
}
|
||
local os_keywords = {"windows", "linux", "macintosh"}
|
||
local machine_keywords = {"apachebench", "curl", "headlesschrome", "wget", "spider", "crawler", "scrapy", "zgrab", "python", "java"}
|
||
|
||
local function _match_client_table(self, ua)
|
||
local client_stat_fields = {}
|
||
|
||
if not ua then
|
||
return client_stat_fields
|
||
end
|
||
|
||
if "table" == type(ua) then
|
||
ua = self:to_json(ua)
|
||
end
|
||
|
||
local lower_ua = string.lower(ua)
|
||
|
||
local is_mobile = false
|
||
for _, kw in ipairs(mobile_keywords) do
|
||
if string.find(lower_ua, kw, 1, true) then
|
||
is_mobile = true
|
||
client_stat_fields["mobile"] = 1
|
||
if kw ~= "mobile" then
|
||
client_stat_fields[clients_map[kw]] = 1
|
||
end
|
||
break
|
||
end
|
||
end
|
||
|
||
if not is_mobile then
|
||
local cls_pc = nil
|
||
for kw, name in pairs(pc_browser_keywords) do
|
||
if string.find(lower_ua, kw, 1, true) then
|
||
cls_pc = name
|
||
break
|
||
end
|
||
end
|
||
|
||
if cls_pc then
|
||
client_stat_fields[cls_pc] = 1
|
||
else
|
||
local is_machine = false
|
||
for _, kw in ipairs(machine_keywords) do
|
||
if string.find(lower_ua, kw, 1, true) then
|
||
is_machine = true
|
||
break
|
||
end
|
||
end
|
||
|
||
if is_machine then
|
||
client_stat_fields["machine"] = 1
|
||
else
|
||
client_stat_fields["other"] = 1
|
||
end
|
||
end
|
||
|
||
for _, kw in ipairs(os_keywords) do
|
||
if string.find(lower_ua, kw, 1, true) then
|
||
client_stat_fields[clients_map[kw]] = 1
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
if string.find(lower_ua, "micromessenger", 1, true) then
|
||
client_stat_fields["weixin"] = 1
|
||
end
|
||
|
||
return client_stat_fields
|
||
end
|
||
|
||
function _M.match_client_arr(self, ua)
|
||
return _match_client_table(self, ua)
|
||
end
|
||
|
||
function _M.match_client(self, ua)
|
||
local client_stat_fields = _match_client_table(self, ua)
|
||
local fields_str = ""
|
||
|
||
for k, v in pairs(client_stat_fields) do
|
||
fields_str = fields_str .. "," .. self:get_update_field(k, v)
|
||
end
|
||
|
||
if fields_str ~= "" then
|
||
fields_str = string.sub(fields_str, 2)
|
||
end
|
||
|
||
return fields_str
|
||
end
|
||
|
||
function _M.get_client_ip(self)
|
||
local client_ip = "unknown"
|
||
if auto_config and auto_config['cdn'] == true then
|
||
for _, v in ipairs(auto_config['cdn_headers'] or {}) do
|
||
local header_var = "http_" .. string.gsub(string.lower(v), "-", "_")
|
||
local ip_list = ngx.var[header_var]
|
||
if ip_list ~= nil and ip_list ~= "" then
|
||
client_ip = self:split(ip_list, ',')[1]
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
if type(client_ip) == 'table' then client_ip = "" end
|
||
if client_ip ~= "unknown" and ngx.re.match(client_ip,"^([a-fA-F0-9]*):") then
|
||
return client_ip
|
||
end
|
||
|
||
if not ngx.re.match(client_ip,"\\d+\\.\\d+\\.\\d+\\.\\d+") == nil or not self:is_ipaddr(client_ip) then
|
||
client_ip = ngx.var.remote_addr
|
||
if client_ip == nil then
|
||
client_ip = "unknown"
|
||
end
|
||
end
|
||
|
||
return client_ip
|
||
end
|
||
|
||
return _M |